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

refactor: replace getRandomFallbackImage with getFallbackImage and reduce ad counts

- Replace getRandomFallbackImage() with getFallbackImage(index) for deterministic fallback images
- Reduce advertisement count from 3 to 2 in News section
- Reduce sidebar advertisements from 5 to 3 in ArticleDetailPage
- Add image validation and optimization for SEO metadata
- Implement toOptimizedSeoImageUrl() to serve optimized images via Next.js image optimizer
- Add isRemoteImageValid() to verify remote image
parent 20d86372
...@@ -5,7 +5,7 @@ import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts"; ...@@ -5,7 +5,7 @@ import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react"; import { useMemo } from "react";
import { getRandomFallbackImage } from "@/lib/utils/fallback-image"; import { getFallbackImage } from "@/lib/utils/fallback-image";
function Events() { function Events() {
const { eventPosts, categoryLinks, categoryNames } = useHomePosts(); const { eventPosts, categoryLinks, categoryNames } = useHomePosts();
...@@ -16,9 +16,9 @@ function Events() { ...@@ -16,9 +16,9 @@ function Events() {
const eventsLink = const eventsLink =
categoryLinks.get(categoryNames.suKien.toLowerCase()) ?? "/hoat-dong/su-kien"; categoryLinks.get(categoryNames.suKien.toLowerCase()) ?? "/hoat-dong/su-kien";
const featuredFallback = useMemo(() => getRandomFallbackImage(), []); const featuredFallback = useMemo(() => getFallbackImage(0), []);
const sideFallbacks = useMemo( const sideFallbacks = useMemo(
() => Array.from({ length: 4 }, () => getRandomFallbackImage()), () => Array.from({ length: 4 }, (_, i) => getFallbackImage(i + 1)),
[], [],
); );
......
...@@ -7,7 +7,7 @@ import { ChevronRight } from "lucide-react"; ...@@ -7,7 +7,7 @@ import { ChevronRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react"; import { useMemo } from "react";
import HorizontalAdBanner from "@/app/(main)/(home)/components/horizontal-ad-banner"; import HorizontalAdBanner from "@/app/(main)/(home)/components/horizontal-ad-banner";
import { getRandomFallbackImage } from "@/lib/utils/fallback-image"; import { getFallbackImage } from "@/lib/utils/fallback-image";
const FALLBACK_CATEGORY_LINK = "/hoat-dong/tin-tuc"; const FALLBACK_CATEGORY_LINK = "/hoat-dong/tin-tuc";
...@@ -19,9 +19,9 @@ function FeaturedNews() { ...@@ -19,9 +19,9 @@ function FeaturedNews() {
const featuredOverviewLink = const featuredOverviewLink =
categoryLinks.get(categoryNames.tinVcci.toLowerCase()) ?? FALLBACK_CATEGORY_LINK; categoryLinks.get(categoryNames.tinVcci.toLowerCase()) ?? FALLBACK_CATEGORY_LINK;
const primaryFallback = useMemo(() => getRandomFallbackImage(), []); const primaryFallback = useMemo(() => getFallbackImage(0), []);
const secondaryFallbacks = useMemo( const secondaryFallbacks = useMemo(
() => [getRandomFallbackImage(), getRandomFallbackImage()], () => [getFallbackImage(1), getFallbackImage(2)],
[], [],
); );
......
...@@ -30,10 +30,10 @@ function News() { ...@@ -30,10 +30,10 @@ function News() {
(tab === "all" (tab === "all"
? categoryLinks.get(categoryNames.tinVcci.toLowerCase()) ? categoryLinks.get(categoryNames.tinVcci.toLowerCase())
: tab === "tin-kinh-te" : tab === "tin-kinh-te"
? categoryLinks.get(categoryNames.tinKinhTe.toLowerCase()) ? categoryLinks.get(categoryNames.tinKinhTe.toLowerCase())
: tab === "chuyen-de" : tab === "chuyen-de"
? categoryLinks.get(categoryNames.chuyenDe.toLowerCase()) ? categoryLinks.get(categoryNames.chuyenDe.toLowerCase())
: categoryLinks.get(categoryNames.tinVcci.toLowerCase())) ?? "/hoat-dong/tin-tuc"; : categoryLinks.get(categoryNames.tinVcci.toLowerCase())) ?? "/hoat-dong/tin-tuc";
return ( return (
<div className="flex-1"> <div className="flex-1">
...@@ -54,11 +54,10 @@ function News() { ...@@ -54,11 +54,10 @@ function News() {
key={item.id} key={item.id}
type="button" type="button"
onClick={() => setTab(item.id)} onClick={() => setTab(item.id)}
className={`rounded-full px-5 py-2.5 text-[14px] font-semibold transition-all ${ className={`rounded-full px-5 py-2.5 text-[14px] font-semibold transition-all ${active
active
? "bg-[#1f5ba9] text-white shadow-[0_10px_20px_rgba(31,91,169,0.18)]" ? "bg-[#1f5ba9] text-white shadow-[0_10px_20px_rgba(31,91,169,0.18)]"
: "bg-[#f4f7fb] text-[#7f8eab] hover:bg-[#eaf0f8]" : "bg-[#f4f7fb] text-[#7f8eab] hover:bg-[#eaf0f8]"
}`} }`}
> >
{item.label} {item.label}
</button> </button>
...@@ -360,14 +359,14 @@ function News() { ...@@ -360,14 +359,14 @@ function News() {
</div> </div>
</div> </div>
<div className="mt-4 hidden justify-end xl:flex"> {/* <div className="mt-4 hidden justify-end xl:flex">
<Link <Link
href={overviewLink} href={overviewLink}
className="text-sm font-semibold text-[#24469c] transition-colors hover:text-[#1b55a1]" className="text-sm font-semibold text-[#24469c] transition-colors hover:text-[#1b55a1]"
> >
Xem tất cả Xem tất cả
</Link> </Link>
</div> </div> */}
</div> </div>
); );
} }
......
...@@ -6,7 +6,7 @@ import { useMemo, useState } from "react"; ...@@ -6,7 +6,7 @@ import { useMemo, useState } 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 links from "@/links";
import type { Advertisement } from "@/api/vcci-news/models/advertisement"; import type { Advertisement } from "@/api/vcci-news/models/advertisement";
import { getRandomFallbackImage } from "@/lib/utils/fallback-image"; import { getFallbackImage } from "@/lib/utils/fallback-image";
const FALLBACK_HREF = links.externalApiOrigin; const FALLBACK_HREF = links.externalApiOrigin;
...@@ -65,9 +65,8 @@ function Advertisements({ count = 2, startIndex = 0 }: { count?: number; startIn ...@@ -65,9 +65,8 @@ function Advertisements({ count = 2, startIndex = 0 }: { count?: number; startIn
const ads = useAdvertisements("square"); const ads = useAdvertisements("square");
const visibleAds = ads.slice(startIndex, startIndex + count); const visibleAds = ads.slice(startIndex, startIndex + count);
// Random fallback images ổn định trong 1 session render
const fallbackSrcs = useMemo( const fallbackSrcs = useMemo(
() => Array.from({ length: count }, () => getRandomFallbackImage()), () => Array.from({ length: count }, (_, i) => getFallbackImage(i)),
[count], [count],
); );
......
...@@ -22,7 +22,7 @@ export default function HomePage() { ...@@ -22,7 +22,7 @@ export default function HomePage() {
<section className="flex flex-col xl:flex-row pb-8 gap-5 mb-0"> <section className="flex flex-col xl:flex-row pb-8 gap-5 mb-0">
<News /> <News />
<Advertisements count={3} startIndex={0} /> <Advertisements count={2} startIndex={0} />
</section > </section >
<HorizontalAdBanner /> <HorizontalAdBanner />
...@@ -34,17 +34,6 @@ export default function HomePage() { ...@@ -34,17 +34,6 @@ export default function HomePage() {
<div className="flex flex-col lg:flex-row gap-5" > <div className="flex flex-col lg:flex-row gap-5" >
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
{/* <div>
<Link href="https://vcci-hcm.org.vn/wp-content/uploads/2022/11/MEDIA-KIT_VCCI-HCM-2022-Final.pdf">
<ImageNext
src="/home/Standard-Banner-1-2024.png.webp"
alt="banner"
width={2560}
height={720}
/>
</Link>
</div> */}
<section className="flex flex-col xl:flex-row gap-5"> <section className="flex flex-col xl:flex-row gap-5">
<div className="flex flex-col md:flex-row gap-5 pt-8 flex-1 order-2 xl:order-1"> <div className="flex flex-col md:flex-row gap-5 pt-8 flex-1 order-2 xl:order-1">
<BusinessOpportunities /> <BusinessOpportunities />
...@@ -53,17 +42,6 @@ export default function HomePage() { ...@@ -53,17 +42,6 @@ export default function HomePage() {
<Advertisements count={2} startIndex={3} /> <Advertisements count={2} startIndex={3} />
</section> </section>
</div> </div>
{/* <div className="w-full lg:w-[30%] justify-center items-start flex">
<Link href="https://smartgara.ecaraid.com/">
<ImageNext
src="/home/eCarAid_web_banner_600x400.webp"
alt="banner"
width={600}
height={400}
/>
</Link>
</div> */}
</div > </div >
<Members /> <Members />
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
getDynamicPostSeoImage, getDynamicPostSeoImage,
getDynamicPostExcerpt, getDynamicPostExcerpt,
stripHtml, stripHtml,
toOptimizedSeoImageUrl,
} from "./templates/data"; } from "./templates/data";
import DynamicPageClient from "./DynamicPageClient"; import DynamicPageClient from "./DynamicPageClient";
...@@ -14,6 +15,32 @@ type GenerateMetadataArgs = { ...@@ -14,6 +15,32 @@ type GenerateMetadataArgs = {
searchParams: Promise<{ id?: string; categoryId?: string }>; searchParams: Promise<{ id?: string; categoryId?: string }>;
}; };
/**
* Verify that a remote image URL actually serves an image (not an HTML 404
* page or redirect). Returns true for relative paths and on network errors
* (benefit of the doubt) so we only fall back when we're certain the URL is
* not an image.
*/
async function isRemoteImageValid(url: string): Promise<boolean> {
if (!url || !/^https?:\/\//i.test(url)) return true;
try {
const response = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(5000),
redirect: "follow",
});
// If the server rejects HEAD, give the benefit of the doubt
if (!response.ok) return true;
const contentType = response.headers.get("content-type") ?? "";
return contentType.startsWith("image/");
} catch {
return true;
}
}
export async function generateMetadata({ export async function generateMetadata({
params, params,
searchParams, searchParams,
...@@ -46,15 +73,18 @@ export async function generateMetadata({ ...@@ -46,15 +73,18 @@ export async function generateMetadata({
stripHtml(post.summary) || stripHtml(post.summary) ||
getDynamicPostExcerpt(post).slice(0, 160) || getDynamicPostExcerpt(post).slice(0, 160) ||
"Tin tức từ VCCI HCM"; "Tin tức từ VCCI HCM";
const imageUrl = getDynamicPostSeoImage(post); const rawImageUrl = getDynamicPostSeoImage(post);
const articleUrl = `${links.siteURL.replace(/\/+$/, "")}${path}${ const isImageValid = await isRemoteImageValid(rawImageUrl);
postId || categoryIdParam const imageUrl = isImageValid
? `?${new URLSearchParams({ ? toOptimizedSeoImageUrl(rawImageUrl)
...(postId && { id: postId }), : toOptimizedSeoImageUrl("/thumbnail.png");
...(categoryIdParam && { categoryId: categoryIdParam }), const articleUrl = `${links.siteURL.replace(/\/+$/, "")}${path}${postId || categoryIdParam
}).toString()}` ? `?${new URLSearchParams({
: "" ...(postId && { id: postId }),
}`; ...(categoryIdParam && { categoryId: categoryIdParam }),
}).toString()}`
: ""
}`;
return { return {
title, title,
......
...@@ -255,7 +255,7 @@ export default function ArticleDetailPage({ ...@@ -255,7 +255,7 @@ export default function ArticleDetailPage({
<aside className="space-y-5 xl:pt-0"> <aside className="space-y-5 xl:pt-0">
<EventsCalendar compact className="xl:w-full xl:min-w-0" /> <EventsCalendar compact className="xl:w-full xl:min-w-0" />
<SidebarAdvertisements count={5} startIndex={0} /> <SidebarAdvertisements count={3} startIndex={0} />
</aside> </aside>
</div> </div>
</div> </div>
......
...@@ -551,6 +551,45 @@ export function getDynamicPostSeoImage(post: DynamicPostItem | null): string { ...@@ -551,6 +551,45 @@ export function getDynamicPostSeoImage(post: DynamicPostItem | null): string {
return "/thumbnail.png"; return "/thumbnail.png";
} }
/**
* Wrap an image URL through Next.js's built-in image optimizer so social media
* crawlers receive a reasonably-sized image (max ~a few hundred KB) instead of
* the original full-resolution upload (which can be 5+ MB and get rejected by
* Twitter/Zalo/Facebook).
*
* Only absolute URLs on the configured remotePatterns host are optimized.
* Relative paths (e.g. "/thumbnail.png") and unsupported hosts are returned
* as-is so we never produce a broken og:image.
*/
export function toOptimizedSeoImageUrl(imageUrl: string): string {
if (!imageUrl) return imageUrl;
// Already optimized, data/blob URIs, or relative paths -> use as-is
if (
imageUrl.startsWith("/_next/image") ||
imageUrl.startsWith("data:") ||
imageUrl.startsWith("blob:")
) {
return imageUrl;
}
if (!/^https?:\/\//i.test(imageUrl)) return imageUrl;
// Only optimize URLs whose host is allowed by next.config images.remotePatterns
const allowedHosts = ["vcci-hcm.org.vn", "vccihcm.vn"];
const parsed = (() => {
try {
return new URL(imageUrl);
} catch {
return null;
}
})();
if (!parsed || !allowedHosts.includes(parsed.hostname)) return imageUrl;
const siteUrl = (Links.siteURL || "").replace(/\/+$/, "");
return `${siteUrl}/_next/image?url=${encodeURIComponent(imageUrl)}&w=1200&q=75`;
}
export function stripHtml(value?: string | null) { export function stripHtml(value?: string | null) {
if (!value) return ""; if (!value) return "";
......
import Header from "@/app/(main)/_lib/layout/header"; import Header from "@/components/layout/main/header";
import Footer from "@/app/(main)/_lib/layout/footer"; import Footer from "@/components/layout/main/footer";
import React from "react"; import React from "react";
import ScrollToTopButton from "./_lib/layout/ScrollToTopButton"; import ScrollToTopButton from "../../components/layout/main/ScrollToTopButton";
export default function Layout({ export default function Layout({
children, children,
......
...@@ -5,9 +5,9 @@ import { usePathname } from 'next/navigation'; ...@@ -5,9 +5,9 @@ import { usePathname } from 'next/navigation';
import { import {
AdminAuthLoadingScreen, AdminAuthLoadingScreen,
useAdminAuthStatus, useAdminAuthStatus,
} from '@/components/shared/admin-auth-guard'; } from '@/components/layout/admin/admin-auth-guard';
import { AdminSidebar } from '@/components/shared/admin-sidebar'; import { AdminSidebar } from '@/components/layout/admin/admin-sidebar';
import { AdminHeader } from '@/components/shared/admin-header'; import { AdminHeader } from '@/components/layout/admin/admin-header';
import { useSidebarStore } from '@/hooks/use-admin-sidebar'; import { useSidebarStore } from '@/hooks/use-admin-sidebar';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
......
@import '../styles/index.css'; @import 'tailwindcss';
/* shared styles are in `src/styles/index.css` */ @import 'tailwindcss/preflight' layer(base);
@import 'tailwindcss/theme' layer(theme);
@plugin "tailwindcss-animate";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--font-reddit-sans: RedditSans, sans-serif;
--default-font-family: 'Roboto', 'Helvetica Neue', Arial, system-ui, -apple-system, 'Segoe UI', 'Noto Sans', sans-serif;
--radius-lg: hsl(var(--radius));
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-secondary: hsl(var(--accent-secondary));
--color-accent-table-header: hsl(var(--accent-table-header));
--color-accent-table-header-foreground: hsl(var(--accent-table-header-foreground));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-accent-information: hsl(var(--accent-information));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--color-sidebar: hsl(var(--sidebar-background));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-side-bar-primary: hsl(var(--sidebar-primary));
--color-side-bar-primary-foreground: hslvar(--sidebar-primary-foreground);
--color-side-bar-accent: hsl(var(--sidebar-accent));
--color-side-bar-accent-foreground: hslvar(--sidebar-accent-foreground);
--color-side-bar-border: hsl(var(--sidebar-border));
--color-side-bar-ring: hsl(var(--sidebar-ring));
--color-header-primary: hsl(var(--header-primary));
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
body {
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;
}
:root {
--background: 0 0% 96%;
--foreground: 0 0% 11%;
--primary-orange: 36 100% 55%;
--card: 0 0% 98%;
--card-foreground: 0 0% 14%;
--popover: 0, 0%, 98%;
--popover-foreground: 0 0% 15%;
--primary: 215 92% 29%;
--primary-foreground: 0 0% 100%;
--primary-orange: 36 100% 55%;
--secondary: 175 36% 88%;
--secondary-foreground: 158 83% 14%;
--muted: 0 0% 91%;
--muted-foreground: 0 0% 45%;
--accent: 175 32% 92%;
--accent-foreground: 159 83% 7%;
--destructive: 357 76% 49%;
--destructive-foreground: 0 0% 100%;
--success: 138 75% 22%;
--success-foreground: 136 100% 95%;
--info: 220 68% 52%;
--info-foreground: 217 100% 91%;
--warning: 25 100% 58%;
--warning-foreground: 46 92% 95%;
--border: 0 0% 90%;
--input: 214, 30%, 91%;
--ring: 158 83% 14%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 210 20% 98%;
--sidebar-foreground: 0 0% 11%;
--sidebar-primary: 158 83% 14%;
--sidebar-primary-foreground: 156 100% 99%;
--sidebar-accent: 175 32% 92%;
--sidebar-accent-foreground: 159 83% 7%;
--sidebar-border: 0 0% 89.8%;
--sidebar-ring: 158 83% 14%;
--header-primary: 158 100% 12%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 227 53% 28%;
--primary-orange: 36 100% 55%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 210 40% 98%;
--warning: 48 96% 89%;
--warning-foreground: 38 92% 50%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--header-primary: 162, 15%, 17%;
}
}
:root { :root {
--primary-color: #0043ce; --primary-color: #0043ce;
...@@ -8,7 +211,6 @@ ...@@ -8,7 +211,6 @@
--text-color: #333333; --text-color: #333333;
--border-color: #eeeeee; --border-color: #eeeeee;
/* Tiptap text colors */
--tiptap-stone-foreground: oklch(0.27 0.01 34); --tiptap-stone-foreground: oklch(0.27 0.01 34);
--tiptap-zinc-foreground: oklch(0.27 0.01 286); --tiptap-zinc-foreground: oklch(0.27 0.01 286);
--tiptap-slate-foreground: oklch(0.28 0.04 260); --tiptap-slate-foreground: oklch(0.28 0.04 260);
...@@ -84,6 +286,7 @@ svg { ...@@ -84,6 +286,7 @@ svg {
ul, ul,
ol { ol {
@apply my-1 ms-4 me-0.5 py-1; @apply my-1 ms-4 me-0.5 py-1;
li p { li p {
@apply mt-0.5 mb-0.5; @apply mt-0.5 mb-0.5;
} }
...@@ -92,6 +295,7 @@ svg { ...@@ -92,6 +295,7 @@ svg {
ul { ul {
@apply list-disc; @apply list-disc;
} }
ol { ol {
@apply list-decimal; @apply list-decimal;
} }
...@@ -159,17 +363,170 @@ svg { ...@@ -159,17 +363,170 @@ svg {
@apply cursor-col-resize; @apply cursor-col-resize;
} }
/* Placeholder /
p.is-editor-empty:first-child::before { p.is-editor-empty:first-child::before {
@apply text-muted-foreground pointer-events-none content-[attr(data-placeholder)]; @apply text-muted-foreground pointer-events-none content-[attr(data-placeholder)];
} }
/ Image */
img { img {
@apply rounded-md; @apply rounded-md;
&.ProseMirror-selectednode { &.ProseMirror-selectednode {
@apply outline-2 outline-offset-2; @apply outline-2 outline-offset-2;
} }
} }
} }
.header-base {
@apply z-20;
}
.header-sticky {
@apply sticky top-0;
}
.header-fixed {
@apply fixed top-0 right-0 left-0;
}
.header-menu-scroll {
-ms-overflow-style: none;
scrollbar-width: none;
}
.header-menu-scroll::-webkit-scrollbar {
display: none;
}
.menu-item-underline {
@apply absolute bottom-0 left-1/2 h-[2px] w-0 -translate-x-1/2 rounded-full bg-[#2f57ff] transition-all duration-200;
}
[aria-selected="true"]>.menu-item-underline,
a:hover>.menu-item-underline {
@apply w-[44px];
}
.scrollbar {
scrollbar-color: rgba(6, 62, 142, 0.38) rgba(219, 232, 255, 0.38);
scrollbar-width: thin;
@apply [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-corner]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-[2px] [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-[#063e8e]/35 [&::-webkit-scrollbar-thumb]:bg-clip-padding hover:[&::-webkit-scrollbar-thumb]:bg-[#063e8e]/50 [&::-webkit-scrollbar-track]:rounded-full [&::-webkit-scrollbar-track]:bg-[#dbe8ff]/55;
}
.list-screen-filter-container {
@apply flex flex-col items-stretch justify-between gap-6 xl:flex-row xl:items-start;
}
.client-section-title {
@apply text-[28px] font-bold tracking-normal md:text-[34px];
font-family: var(--default-font-family);
}
.client-footer-title {
@apply text-xl font-bold tracking-normal md:text-2xl;
font-family: var(--default-font-family);
}
.typography-h1 {
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
}
.typography-h2 {
@apply scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0;
}
.typography-h3 {
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
}
.typography-h4 {
@apply scroll-m-20 text-xl font-semibold tracking-tight;
}
.typography-p {
@apply leading-7 [&:not(:first-child)]:mt-6;
}
.typography-table {
@apply my-6 w-full overflow-y-auto;
table {
@apply w-full;
tr {
@apply even:bg-muted m-0 border-t p-0;
}
th,
td {
@apply border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right;
}
th {
@apply font-bold;
}
}
}
.typography-blockquote {
@apply mt-6 border-l-2 pl-6 italic;
}
.typography-list {
@apply my-6 ml-6 list-disc [&>li]:mt-2;
}
.typography-inline-code {
@apply bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold;
}
.typography-lead {
@apply text-muted-foreground text-xl;
}
.typography-large {
@apply text-lg font-semibold;
}
.typography-small {
@apply text-sm leading-none font-medium;
}
.typography-muted {
@apply text-muted-foreground text-sm;
}
} }
@layer utilities {
.container {
@apply mr-auto ml-auto px-4;
@media (width >=80rem) {
@apply px-15;
}
}
.field-size-content {
field-sizing: 'content';
}
.sun-editor-editable {
font-family: 'Be Vietnam Pro', sans-serif;
font-size: 16px;
background-color: transparent;
}
.sun-editor-editable img {
height: auto !important;
}
.sun-editor-editable .se-image-container figure {
width: 100% !important;
display: flex;
justify-content: center;
}
.sun-editor-editable th,
.sun-editor-editable td {
padding: 6px !important;
}
}
\ No newline at end of file
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./styles.css"; import "./globals.css";
import { Providers } from "./_providers"; import { Providers } from "./_providers";
import React from "react"; import React from "react";
import links from "@links/index"; import links from "@links/index";
......
...@@ -6,7 +6,7 @@ import { useMemo, useState } from "react"; ...@@ -6,7 +6,7 @@ import { useMemo, useState } 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 links from "@/links";
import type { Advertisement } from "@/api/vcci-news/models/advertisement"; import type { Advertisement } from "@/api/vcci-news/models/advertisement";
import { getRandomFallbackImage } from "@/lib/utils/fallback-image"; import { getFallbackImage } from "@/lib/utils/fallback-image";
const FALLBACK_HREF = "https://vcci-hcm.org.vn"; const FALLBACK_HREF = "https://vcci-hcm.org.vn";
...@@ -65,9 +65,8 @@ function SidebarAdvertisements({ count = 5, startIndex = 0 }: { count?: number; ...@@ -65,9 +65,8 @@ function SidebarAdvertisements({ count = 5, startIndex = 0 }: { count?: number;
const ads = useAdvertisements("square"); const ads = useAdvertisements("square");
const visibleAds = ads.slice(startIndex, startIndex + count); const visibleAds = ads.slice(startIndex, startIndex + count);
// Random fallback images ổn định trong 1 session render
const fallbackSrcs = useMemo( const fallbackSrcs = useMemo(
() => Array.from({ length: count }, () => getRandomFallbackImage()), () => Array.from({ length: count }, (_, i) => getFallbackImage(i)),
[count], [count],
); );
......
/**
* Danh sách ảnh fallback trong /public/fallback.
* Dùng khi API không trả thumbnail hoặc ảnh lỗi.
* Random 1 ảnh mỗi lần gọi để tránh nhàm chán.
*/
const FALLBACK_IMAGES = [ const FALLBACK_IMAGES = [
"/fallback/file-1787627024817-122290329.jpg", "/fallback/file-1787627024817-122290329.jpg",
"/fallback/file-1787628149029-832000088.jpg", "/fallback/file-1787628149029-832000088.jpg",
...@@ -13,6 +8,6 @@ const FALLBACK_IMAGES = [ ...@@ -13,6 +8,6 @@ const FALLBACK_IMAGES = [
"/fallback/thumbnail.png", "/fallback/thumbnail.png",
] as const; ] as const;
export const getRandomFallbackImage = (): string => { export const getFallbackImage = (index = 0): string => {
return FALLBACK_IMAGES[Math.floor(Math.random() * FALLBACK_IMAGES.length)]; return FALLBACK_IMAGES[Math.abs(index) % FALLBACK_IMAGES.length];
}; };
@import 'tailwindcss/preflight' layer(base);
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
body {
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 */
--background: 0 0% 96%;
--foreground: 0 0% 11%;
--primary-orange: 36 100% 55%;
/* Background color for <Card /> */
--card: 0 0% 98%;
--card-foreground: 0 0% 14%;
/* Background color for popovers such as <DropdownMenu />, <HoverCard />, <Popover /> */
--popover: 0, 0%, 98%;
--popover-foreground: 0 0% 15%;
/* Primary colors for <Button /> */
--primary: 215 92% 29%;
--primary-foreground: 0 0% 100%;
--primary-orange: 36 100% 55%;
/* Secondary colors for <Button /> */
--secondary: 175 36% 88%;
--secondary-foreground: 158 83% 14%;
/* Muted backgrounds such as <TabsList />, <Skeleton /> and <Switch /> */
--muted: 0 0% 91%;
--muted-foreground: 0 0% 45%;
/* Used for accents such as hover effects on <DropdownMenuItem>, <SelectItem>...etc */
--accent: 175 32% 92%;
--accent-foreground: 159 83% 7%;
/* Used for destructive actions such as <Button variant="destructive"> */
--destructive: 357 76% 49%;
--destructive-foreground: 0 0% 100%;
/* Used for success actions such as <Button variant="success"> */
--success: 138 75% 22%;
--success-foreground: 136 100% 95%;
/* Used for info actions such as <Button variant="info"> */
--info: 220 68% 52%;
--info-foreground: 217 100% 91%;
/* Used for warning actions such as <Button variant="warning"> */
--warning: 25 100% 58%;
--warning-foreground: 46 92% 95%;
/* Default border color */
--border: 0 0% 90%;
/* Border color for inputs such as <Input />, <Select />, <Textarea /> */
--input: 214, 30%, 91%;
/* Used for focus ring */
--ring: 158 83% 14%;
/* Border radius for card, input and buttons */
--radius: 0.5rem;
/* Charts */
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
/* Sidebar */
--sidebar-background: 210 20% 98%;
--sidebar-foreground: 0 0% 11%;
--sidebar-primary: 158 83% 14%;
--sidebar-primary-foreground: 156 100% 99%;
--sidebar-accent: 175 32% 92%;
--sidebar-accent-foreground: 159 83% 7%;
--sidebar-border: 0 0% 89.8%;
--sidebar-ring: 158 83% 14%;
/* Header */
--header-primary: 158 100% 12%;
}
.dark {
/* Default background color of <body />...etc */
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
/* Background color for <Card /> */
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
/* Background color for popovers such as <DropdownMenu />, <HoverCard />, <Popover /> */
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
/* Primary colors for <Button /> */
--primary: 0 0% 98%;
--primary-foreground: 227 53% 28%;
--primary-orange: 36 100% 55%;
/* USER DEFINED */
/* Secondary colors for <Button /> */
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
/* Muted backgrounds such as <TabsList />, <Skeleton /> and <Switch /> */
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
/* Used for accents such as hover effects on <DropdownMenuItem>, <SelectItem>...etc */
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
/* Used for destructive actions such as <Button variant="destructive"> */
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
/* Used for success actions */
--success: 142.1 76.2% 36.3%;
--success-foreground: 210 40% 98%;
/* Used for warning actions */
--warning: 48 96% 89%;
--warning-foreground: 38 92% 50%;
/* Default border color */
--border: 0 0% 14.9%;
/* Border color for inputs such as <Input />, <Select />, <Textarea /> */
--input: 0 0% 14.9%;
/* Used for focus ring */
--ring: 0 0% 83.1%;
/* Charts */
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
/* Sidebar */
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* Header */
--header-primary: 162, 15%, 17%;
}
}
@layer components {
/* Header */
.header-base {
@apply z-20;
}
.header-sticky {
@apply sticky top-0;
}
.header-fixed {
@apply fixed top-0 right-0 left-0;
}
.header-menu-scroll {
-ms-overflow-style: none;
scrollbar-width: none;
}
.header-menu-scroll::-webkit-scrollbar {
display: none;
}
.menu-item-underline {
@apply absolute bottom-0 left-1/2 h-[2px] w-0 -translate-x-1/2 rounded-full bg-[#2f57ff] transition-all duration-200;
}
[aria-selected="true"] > .menu-item-underline,
a:hover > .menu-item-underline {
@apply w-[44px];
}
/* Scrollbar */
.scrollbar {
scrollbar-color: rgba(6, 62, 142, 0.38) rgba(219, 232, 255, 0.38);
scrollbar-width: thin;
@apply [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-corner]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-[2px] [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-[#063e8e]/35 [&::-webkit-scrollbar-thumb]:bg-clip-padding hover:[&::-webkit-scrollbar-thumb]:bg-[#063e8e]/50 [&::-webkit-scrollbar-track]:rounded-full [&::-webkit-scrollbar-track]:bg-[#dbe8ff]/55;
}
.list-screen-filter-container {
@apply flex flex-col items-stretch justify-between gap-6 xl:flex-row xl:items-start;
}
.client-section-title {
@apply text-[28px] font-bold tracking-normal md:text-[34px];
font-family: var(--default-font-family);
}
.client-footer-title {
@apply text-xl font-bold tracking-normal md:text-2xl;
font-family: var(--default-font-family);
}
/* Typography */
.typography-h1 {
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
}
.typography-h2 {
@apply scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0;
}
.typography-h3 {
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
}
.typography-h4 {
@apply scroll-m-20 text-xl font-semibold tracking-tight;
}
.typography-p {
@apply leading-7 [&:not(:first-child)]:mt-6;
}
.typography-table {
@apply my-6 w-full overflow-y-auto;
table {
@apply w-full;
tr {
@apply even:bg-muted m-0 border-t p-0;
}
th,
td {
@apply border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right;
}
th {
@apply font-bold;
}
}
}
.typography-blockquote {
@apply mt-6 border-l-2 pl-6 italic;
}
.typography-list {
@apply my-6 ml-6 list-disc [&>li]:mt-2;
}
.typography-inline-code {
@apply bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold;
}
.typography-lead {
@apply text-muted-foreground text-xl;
}
.typography-large {
@apply text-lg font-semibold;
}
.typography-small {
@apply text-sm leading-none font-medium;
}
.typography-muted {
@apply text-muted-foreground text-sm;
}
}
@import 'tailwindcss/theme' layer(theme);
@theme inline {
/* Fonts */
--font-reddit-sans: RedditSans, sans-serif;
--default-font-family: 'Roboto', 'Helvetica Neue', Arial, system-ui, -apple-system, 'Segoe UI', 'Noto Sans', sans-serif;
/* Border Radius */
--radius-lg: hsl(var(--radius));
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
/* Colors */
/* Containers */
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
/* Cards */
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
/* Popovers */
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
/* Primaries */
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
/* Secondaries */
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
/* Muted */
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
/* Accent */
--color-accent: hsl(var(--accent));
--color-accent-secondary: hsl(var(--accent-secondary));
--color-accent-table-header: hsl(var(--accent-table-header));
--color-accent-table-header-foreground: hsl(var(--accent-table-header-foreground));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-accent-information: hsl(var(--accent-information));
/* Destructive */
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
/* Success */
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
/* Border */
--color-border: hsl(var(--border));
/* Input */
--color-input: hsl(var(--input));
/* Ring */
--color-ring: hsl(var(--ring));
/* Chart */
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
/* Sidebar */
--color-sidebar: hsl(var(--sidebar-background));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-side-bar-primary: hsl(var(--sidebar-primary));
--color-side-bar-primary-foreground: hslvar(--sidebar-primary-foreground);
--color-side-bar-accent: hsl(var(--sidebar-accent));
--color-side-bar-accent-foreground: hslvar(--sidebar-accent-foreground);
--color-side-bar-border: hsl(var(--sidebar-border));
--color-side-bar-ring: hsl(var(--sidebar-ring));
/* Header */
--color-header-primary: hsl(var(--header-primary));
}
\ No newline at end of file
@layer utilities {
.container {
@apply mr-auto ml-auto px-4;
@media (width >= 80rem) {
@apply px-15;
}
}
.field-size-content {
field-sizing: 'content';
}
.sun-editor-editable {
font-family: 'Be Vietnam Pro', sans-serif;
font-size: 16px;
background-color: transparent;
}
.sun-editor-editable img {
height: auto !important;
}
.sun-editor-editable .se-image-container figure {
width: 100% !important;
display: flex;
justify-content: center;
}
.sun-editor-editable th,
.sun-editor-editable td {
padding: 6px !important;
}
}
\ No newline at end of file
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* Configs */
@import './_base.css';
@import './_themes.css';
@import './_components.css';
@import './_utilities.css';
/* Plugins */
@plugin "tailwindcss-animate";
/* Source */
@source "../../modules/ui";
declare module "swiper/css";
declare module "swiper/css/bundle";
declare module "swiper/css/effect-fade";
declare module "swiper/css/navigation";
declare module "swiper/css/pagination";
declare module "swiper/css/scrollbar";
declare module "swiper/css/thumbs";
declare module "swiper/css/free-mode";
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