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

optimize image

parent cd1a8b50
......@@ -10,6 +10,7 @@ const orvalConfig = defineConfig({
target: "src/api/vcci-news/endpoints/index.ts",
schemas: "src/api/vcci-news/models",
client: "react-query",
clean: true,
override: {
query: {
useInfinite: true,
......
......@@ -152,6 +152,9 @@ importers:
react-resizable-panels:
specifier: ^3.0.6
version: 3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
sharp:
specifier: 0.34.5
version: 0.34.5
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
......@@ -4251,8 +4254,7 @@ snapshots:
transitivePeerDependencies:
- encoding
'@img/colour@1.0.0':
optional: true
'@img/colour@1.0.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
......@@ -7466,7 +7468,6 @@ snapshots:
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
optional: true
shebang-command@2.0.0:
dependencies:
......
"use client";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay } from "swiper/modules";
import { Swiper as SwiperType } from "swiper/types";
......@@ -84,9 +84,8 @@ function BannerSlideItem({
: "/thumbnail.png";
return (
<SafeImage
<Image
src={url}
fallbackSrc="/thumbnail.png"
alt={alt}
width={2560}
height={720}
......
'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import Link from "next/link";
......@@ -48,9 +48,8 @@ function Events() {
className="group flex h-full cursor-pointer flex-col overflow-hidden rounded-[14px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]"
>
<div className="relative h-[180px] overflow-hidden md:h-[220px] xl:h-[248px]">
<SafeImage
src={featuredEvent.thumbnail?.url}
fallbackSrc={featuredFallback}
<Image
src={featuredEvent.thumbnail?.url ?? featuredFallback}
alt={featuredEvent.thumbnail?.alt || featuredEvent.title}
width={720}
height={520}
......@@ -115,9 +114,8 @@ function Events() {
className="group flex flex-1 cursor-pointer items-center gap-3 rounded-[14px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] backdrop-blur-sm transition-colors hover:bg-white/14"
>
<div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[14px]">
<SafeImage
src={item.thumbnail?.url}
fallbackSrc={sideFallbacks[index]}
<Image
src={item.thumbnail?.url ?? sideFallbacks[index] ?? featuredFallback}
alt={item.thumbnail?.alt || item.title}
width={160}
height={160}
......
'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
......@@ -51,9 +51,8 @@ function FeaturedNews() {
className="group relative block cursor-pointer overflow-hidden rounded-[16px] bg-[#0d2f5f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] md:rounded-[16px] md:min-h-[320px] lg:min-h-[380px]"
>
<div className="relative h-full min-h-[195px] md:min-h-[320px] lg:min-h-[380px]">
<SafeImage
src={primaryItem.thumbnail?.url}
fallbackSrc={primaryFallback}
<Image
src={primaryItem.thumbnail?.url ?? primaryFallback}
alt={primaryItem.thumbnail?.alt || primaryItem.title}
width={1200}
height={800}
......@@ -98,9 +97,8 @@ function FeaturedNews() {
className="group relative block cursor-pointer overflow-hidden rounded-[16px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] min-h-[165px]"
>
<div className="relative flex h-full min-h-[165px]">
<SafeImage
src={item.thumbnail?.url}
fallbackSrc={secondaryFallbacks[index]}
<Image
src={item.thumbnail?.url ?? secondaryFallbacks[index] ?? primaryFallback}
alt={item.thumbnail?.alt || item.title}
width={600}
height={420}
......
'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import Link from "next/link";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import links from "@/links";
......@@ -27,9 +27,8 @@ function HorizontalAdBanner() {
style={{ aspectRatio: "1600 / 200" }}
title={title}
>
<SafeImage
<Image
src={src}
fallbackSrc={FALLBACK_SRC}
alt={alt}
fill
sizes="100vw"
......
'use client';
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { ChevronRight } from "lucide-react";
import memberImages from "@/constants/memberImages";
import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/mockdata/bff-fallback";
......@@ -113,9 +113,8 @@ function Members() {
>
<div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5">
<div className="flex h-full w-full max-w-[260px] items-center justify-center">
<SafeImage
<Image
src={resolveMemberImage(member.avatar, index)}
fallbackSrc="/img-error.png"
alt={member.name ?? ""}
width={260}
height={180}
......@@ -131,9 +130,8 @@ function Members() {
<>
<div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5">
<div className="flex h-full w-full max-w-[260px] items-center justify-center">
<SafeImage
<Image
src={resolveMemberImage(member.avatar, index)}
fallbackSrc="/img-error.png"
alt={member.name ?? ""}
width={260}
height={180}
......@@ -214,9 +212,8 @@ function Members() {
className="group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<div className="aspect-[16/10] overflow-hidden xl:aspect-[1.25/1]">
<SafeImage
src={item.thumbnail?.url}
fallbackSrc={MEMBER_CONNECTION_FALLBACK_IMAGE}
<Image
src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title}
width={520}
height={420}
......@@ -257,9 +254,8 @@ function Members() {
className="group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<div className="aspect-[16/10] overflow-hidden">
<SafeImage
src={item.thumbnail?.url}
fallbackSrc={MEMBER_CONNECTION_FALLBACK_IMAGE}
<Image
src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title}
width={520}
height={420}
......
'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import Link from "next/link";
......@@ -83,9 +83,8 @@ function News() {
className="group block cursor-pointer overflow-hidden rounded-[16px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<div className="relative aspect-[1.4/1] overflow-hidden">
<SafeImage
src={featuredArticle.thumbnail?.url}
fallbackSrc="/thumbnail.png"
<Image
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720}
height={580}
......@@ -152,9 +151,8 @@ function News() {
className="group flex flex-1 cursor-pointer items-center gap-3 rounded-[16px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)]"
>
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-[14px]">
<SafeImage
src={news.thumbnail?.url}
fallbackSrc="/thumbnail.png"
<Image
src={news.thumbnail?.url ?? "/thumbnail.png"}
alt={news.thumbnail?.alt || news.title}
width={160}
height={160}
......@@ -229,9 +227,8 @@ function News() {
className="group block cursor-pointer overflow-hidden rounded-[16px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<div className="relative aspect-[16/9] overflow-hidden">
<SafeImage
src={featuredArticle.thumbnail?.url}
fallbackSrc="/thumbnail.png"
<Image
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720}
height={580}
......@@ -295,9 +292,8 @@ function News() {
className="group flex cursor-pointer items-center gap-3 rounded-[16px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)]"
>
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-[14px]">
<SafeImage
src={news.thumbnail?.url}
fallbackSrc="/thumbnail.png"
<Image
src={news.thumbnail?.url ?? "/thumbnail.png"}
alt={news.thumbnail?.alt || news.title}
width={160}
height={160}
......
'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import Link from "next/link";
import { useMemo } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
......@@ -22,9 +22,8 @@ function AdItem({ item, fallbackSrc }: { item: Advertisement; fallbackSrc: strin
title={item.name}
>
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
<SafeImage
<Image
src={src}
fallbackSrc={fallbackSrc}
alt={item.alt || item.name}
width={2048}
height={1365}
......@@ -45,7 +44,7 @@ function FallbackAdItem({ src }: { src: string }) {
title="Quảng cáo VCCI HCM"
>
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
<SafeImage
<Image
src={src}
alt="Quảng cáo VCCI HCM"
width={2048}
......
"use client";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import partnerImages from "@/constants/partnerImages";
import { ChevronRight, Play } from "lucide-react";
import Link from "next/link";
......@@ -68,9 +68,8 @@ const renderPartnerContent = (partners: Organization[]) => {
className="block"
>
<div className="flex h-[96px] items-center justify-center rounded-[16px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_24px_rgba(31,59,124,0.1)] xl:h-[151px]">
<SafeImage
<Image
src={resolvePartnerImage(partner.avatar, index)}
fallbackSrc="/img-error.png"
alt={partner.name ?? ""}
width={140}
height={72}
......@@ -80,9 +79,8 @@ const renderPartnerContent = (partners: Organization[]) => {
</a>
) : (
<div className="flex h-[96px] items-center justify-center rounded-[16px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] xl:h-[151px]">
<SafeImage
<Image
src={resolvePartnerImage(partner.avatar, index)}
fallbackSrc="/img-error.png"
alt={partner.name ?? ""}
width={140}
height={72}
......@@ -181,7 +179,7 @@ function VideoAndPartners() {
className="overflow-hidden rounded-[16px] border border-[#e5ebf4] bg-white shadow-[0_10px_22px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_16px_30px_rgba(31,59,124,0.12)]"
>
<div className="group relative aspect-[1.95/1] overflow-hidden">
<SafeImage
<Image
src={video.thumbnail}
alt={video.name}
width={640}
......
......@@ -6,7 +6,7 @@ import parse from "html-react-parser";
import Link from "next/link";
import ListCategory from "@/components/base/list-category";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import links from "@/links";
import {
buildDynamicPostHref,
......@@ -338,7 +338,7 @@ export default function AboutVcciHcm({ post, category, allCategories }: AboutVcc
className="group overflow-hidden rounded-[22px] bg-white shadow-[0_18px_38px_rgba(28,52,120,0.16)] transition-transform hover:-translate-y-1"
>
<div className="relative aspect-[1.28] overflow-hidden">
<SafeImage
<Image
src={item.thumbnailUrl}
alt={item.thumbnailAlt}
width={720}
......
......@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
......@@ -123,7 +123,7 @@ export default function AnPham({ category, allCategories }: AnPhamProps) {
>
<div className="overflow-hidden bg-white shadow-[0_10px_24px_rgba(17,24,39,0.08)]">
<div className="relative aspect-3/4 overflow-hidden bg-white">
<SafeImage
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
......@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { FileText, Globe2, Newspaper, TrendingUp } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import ListCategory from "@/components/base/list-category";
import { buildDynamicCategoryMenu } from "../templates/data";
import type { DynamicCategoryRouteItem } from "../templates/types";
......@@ -266,7 +266,7 @@ export default function MarketProfile({ post, category, allCategories }: MarketP
})}
</div>
<SafeImage
<Image
src={activeRegion.image}
alt={activeRegion.imageAlt}
width={1200}
......
......@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Pagination } from "@components/base/pagination";
import { Button } from "@/components/ui/button";
......@@ -57,7 +57,7 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe
className="group grid gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<div className="overflow-hidden rounded-md bg-[#edf1f5]">
<SafeImage
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
......@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
......@@ -123,7 +123,7 @@ export default function ThuVienTaiLieu({ category, allCategories }: ThuVienTaiLi
>
<div className="overflow-hidden bg-white shadow-[0_10px_24px_rgba(17,24,39,0.08)]">
<div className="relative aspect-3/4 overflow-hidden bg-white">
<SafeImage
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
......@@ -3,7 +3,7 @@
import { Suspense } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Play } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Pagination } from "@/components/base/pagination";
import { Spinner } from "@/components/ui/spinner";
import ListCategory from "@/components/base/list-category";
......@@ -87,7 +87,7 @@ function VideoPageContent() {
className="group overflow-hidden rounded-[18px] border border-[#e5ebf4] bg-white shadow-[0_12px_30px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_38px_rgba(31,59,124,0.14)]"
>
<div className="relative aspect-video overflow-hidden bg-[#edf1f5]">
<SafeImage
<Image
src={video.thumbnail}
alt={video.name}
width={900}
......
......@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
......@@ -158,7 +158,7 @@ export default function NewsPage({ category, allCategories }: NewsPageProps) {
className="group grid items-center gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<div className="relative overflow-hidden rounded-md bg-[#edf1f5] aspect-[25/15] sm:aspect-[5/3]">
<SafeImage
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
......@@ -45,7 +45,7 @@ import {
import { PermissionGate } from "@/components/shared/permission-gate";
import { usePermission } from "@/hooks/usePermission";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import links from "@/links";
import { useQueryClient } from "@tanstack/react-query";
......@@ -369,7 +369,7 @@ export function AdvertisementList({
style={{ aspectRatio: previewAspect, width: type === "horizontal" ? 112 : 72 }}
>
{imageUrl ? (
<SafeImage
<Image
src={imageUrl}
alt={ad.alt || ad.name}
fill
......@@ -514,7 +514,7 @@ export function AdvertisementList({
style={{ aspectRatio: previewAspect, width: type === "horizontal" ? 200 : 120 }}
>
{form.filePreviewUrl ? (
<SafeImage
<Image
src={form.filePreviewUrl}
alt="Preview"
fill
......
"use client";
import Image from "next/image";
import type { BaseConfigBannerItem } from "@/api/vcci-news/types/base-config";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { SafeImage } from "@/components/shared/safe-image";
import { useGetApiV10FileId } from "@/api/vcci-news/endpoints/file";
import { toAdminMediaItem } from "@/lib/utils/file";
function BannerMedia({ fileId }: { fileId: string }) {
const { data } = useGetApiV10FileId(fileId, {
query: {
enabled: Boolean(fileId),
select: (response) =>
response.responseData ? toAdminMediaItem(response.responseData) : null,
},
});
return data ? (
<Image
src={data.url}
alt={data.alt || data.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Chưa chọn hình ảnh
</div>
);
}
export function ConfigItemPreview({
title,
item,
media,
current,
onSelect,
}: {
title: string;
item: BaseConfigBannerItem;
media: AdminMediaItem | null;
current: boolean;
onSelect: () => void;
}) {
......@@ -21,24 +42,10 @@ export function ConfigItemPreview({
<button
type="button"
onClick={onSelect}
className={`overflow-hidden rounded-3xl border text-left transition-all ${current
? "border-[#063e8e]/35 bg-[#edf4ff] shadow-[0_10px_24px_rgba(6,62,142,0.12)]"
: "border-[#063e8e]/10 bg-white hover:border-[#063e8e]/25 hover:shadow-sm"
}`}
className={`overflow-hidden rounded-3xl border text-left transition-all ${current ? "border-[#063e8e]/35 bg-[#edf4ff] shadow-[0_10px_24px_rgba(6,62,142,0.12)]" : "border-[#063e8e]/10 bg-white hover:border-[#063e8e]/25 hover:shadow-sm"}`}
>
<div className="relative aspect-[16/10] overflow-hidden bg-[#eef4ff]">
{media ? (
<SafeImage
src={media.url}
alt={media.alt || media.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Chưa chọn hình ảnh
</div>
)}
<BannerMedia fileId={item.imageId} />
</div>
<div className="space-y-2 px-4 py-3">
<div className="line-clamp-1 text-sm font-semibold text-[#163b73]">
......
import type { Banner, BannerMutate } from "@/api/vcci-news/models";
import type { BaseConfigBannerItem } from "@/mockdata/base-config";
import { createBaseConfigItemId } from "@/mockdata/base-config";
export function mapApiBannerToConfig(banner: Banner): BaseConfigBannerItem {
return {
id: banner.id ?? createBaseConfigItemId("banner"),
name: banner.banner_name ?? "",
imageId: banner.file_id ?? "",
isActive: banner.status !== "INACTIVE",
displayTimeSeconds: banner.display_time ?? 5,
sortOrder: banner.display_order ?? 1,
};
}
export function mapConfigBannerToApi(
banner: BaseConfigBannerItem,
): BannerMutate {
return {
banner_name: banner.name.trim(),
file_id: banner.imageId,
display_order: banner.sortOrder,
display_time: Math.max(1, banner.displayTimeSeconds || 1),
status: banner.isActive ? "ACTIVE" : "INACTIVE",
};
}
This diff is collapsed.
import type { Logo } from "@/api/vcci-news/models";
import type { BaseConfigLogoItem } from "@/mockdata/base-config";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import type { LogoMediaItem } from "../../types";
import links from "@/links";
export function mapApiLogoToConfig(logo: Logo): {
logo: BaseConfigLogoItem;
media: LogoMediaItem;
} {
const media: LogoMediaItem = {
id: logo.file_id,
logoId: logo.id,
name: logo.logo_name,
alt: logo.logo_name,
url: links.resolveImageUrl(logo.logo_url) || "/img-error.png",
mime: "image/*",
size: 0,
created_at: logo.created_at,
updated_at: logo.updated_at,
source: "upload",
};
return {
logo: {
id: logo.id,
name: logo.logo_name,
imageId: logo.file_id,
isActive: true,
},
media,
};
}
export function toLogoMediaItem(logo: Logo): AdminMediaItem {
return mapApiLogoToConfig(logo).media;
}
This diff is collapsed.
......@@ -17,10 +17,7 @@ export function BranchCard({
}) {
return (
<div
className={`rounded-3xl border p-4 transition-all ${current
? "border-[#063e8e]/30 bg-[#eef5ff] shadow-[0_10px_24px_rgba(6,62,142,0.1)]"
: "border-[#063e8e]/10 bg-white"
}`}
className={`rounded-3xl border p-4 transition-all ${current ? "border-[#063e8e]/30 bg-[#eef5ff] shadow-[0_10px_24px_rgba(6,62,142,0.1)]" : "border-[#063e8e]/10 bg-white"}`}
>
<button type="button" onClick={onSelect} className="w-full text-left">
<div className="text-sm font-semibold text-[#163b73]">
......@@ -30,7 +27,6 @@ export function BranchCard({
{branch.address || "Chưa cập nhật địa chỉ"}
</div>
</button>
<div className="mt-4 flex items-center justify-between">
<div className="text-xs text-slate-500">
{branch.hotline || "Chưa có hotline"}
......
import type {
SiteInformationBranch,
SiteInformationBranchMutate,
} from "@/api/vcci-news/models";
import type { BaseConfigBranchItem } from "@/mockdata/base-config";
import { createBaseConfigItemId } from "@/mockdata/base-config";
export function mapApiBranchToConfig(
branch: SiteInformationBranch,
): BaseConfigBranchItem {
return {
id: branch.id ?? createBaseConfigItemId("branch"),
branchName: branch.branch_name ?? "",
address: branch.address ?? "",
hotline: branch.hotline ?? branch.telephone ?? "",
email: branch.email ?? "",
fax: branch.fax ?? "",
mapsEmbedUrl: branch.googlemap_link ?? "",
sortOrder: branch.sort_order ?? 1,
isVisible: branch.is_active ?? true,
};
}
export function mapConfigBranchToApi(
branch: BaseConfigBranchItem,
index: number,
): SiteInformationBranchMutate {
return {
branch_name: branch.branchName.trim() || null,
address: branch.address.trim() || null,
hotline: branch.hotline.trim() || null,
email: branch.email.trim() || null,
fax: branch.fax.trim() || null,
googlemap_link: branch.mapsEmbedUrl.trim() || null,
sort_order: Number.isFinite(branch.sortOrder)
? branch.sortOrder
: index + 1,
is_active: branch.isVisible,
};
}
This diff is collapsed.
import type {
SiteInformationSocialLink,
SiteInformationSocialMutate,
} from "@/api/vcci-news/models";
import type { BaseConfigSocialItem } from "@/mockdata/base-config";
export function mapApiSocialToConfig(
social: SiteInformationSocialLink,
): BaseConfigSocialItem {
return {
id: social.id,
label: social.label,
url: social.url ?? "",
isVisible: social.is_active,
sortOrder: social.sort_order,
};
}
export function mapConfigSocialToApi(
social: BaseConfigSocialItem,
): SiteInformationSocialMutate {
return {
url: social.url.trim() || null,
sort_order: social.sortOrder,
is_active: social.isVisible,
};
}
"use client";
import { useEffect, useState } from "react";
import { Save } from "lucide-react";
import { toast } from "sonner";
import { TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
useGetApiV10SiteInformationSocials,
usePatchApiV10SiteInformationSocialsId,
} from "@/api/vcci-news/endpoints/site-information";
import type { SiteInformationSocialLink } from "@/api/vcci-news/models";
import type { BaseConfigSocialItem } from "@/mockdata/base-config";
import {
mapConfigSocialToApi,
mapApiSocialToConfig,
} from "./_components/utils";
import { fieldClassName } from "../types";
export function SocialTab() {
const { data: socialsData, isLoading: isSocialsLoading } =
useGetApiV10SiteInformationSocials();
const { mutateAsync: updateSocial, isPending: isUpdatingSocials } =
usePatchApiV10SiteInformationSocialsId();
const [socials, setSocials] = useState<BaseConfigSocialItem[]>([]);
const socialsResponse = socialsData as
| {
responseData?: SiteInformationSocialLink[];
data?: { responseData?: SiteInformationSocialLink[] };
}
| undefined;
const apiSocials =
socialsResponse?.responseData ?? socialsResponse?.data?.responseData ?? [];
useEffect(() => {
if (apiSocials.length) setSocials(apiSocials.map(mapApiSocialToConfig));
}, [apiSocials]);
const change = <K extends keyof BaseConfigSocialItem>(
id: string,
key: K,
value: BaseConfigSocialItem[K],
) => {
setSocials((previous) =>
previous.map((social) =>
social.id === id ? { ...social, [key]: value } : social,
),
);
};
const save = async () => {
try {
await Promise.all(
socials.map((social) =>
updateSocial({
id: social.id,
data: mapConfigSocialToApi(social),
}),
),
);
toast.success("Đã lưu cấu hình mạng xã hội");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Không thể lưu cấu hình mạng xã hội",
);
}
};
return (
<TabsContent value="social" className="mt-0" aria-busy={isSocialsLoading}>
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">
Mạng xã hội
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý link mạng xã hội và thứ tự hiển thị.
</CardDescription>
</div>
<Button type="button" onClick={save} disabled={isUpdatingSocials}>
<Save className="mr-2 h-4 w-4" />
{isUpdatingSocials ? "Đang lưu..." : "Lưu cấu hình"}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 sm:px-6">
{socials.map((item) => (
<div
key={item.id}
className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)_180px] lg:items-end">
<div className="flex items-center gap-3 rounded-2xl border bg-white px-4 py-4">
<Checkbox
checked={item.isVisible}
onCheckedChange={(checked) =>
change(item.id, "isVisible", checked === true)
}
/>
<div>
<div className="font-semibold">{item.label}</div>
<div className="text-sm text-slate-500">
{item.isVisible ? "Đang hiển thị" : "Đang ẩn"}
</div>
</div>
</div>
<div className="space-y-2">
<Label>Link URL</Label>
<Input
value={item.url}
onChange={(event) =>
change(item.id, "url", event.target.value)
}
placeholder={`Nhập link ${item.label}...`}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label>Thứ tự hiển thị</Label>
<Input
type="number"
min={1}
value={item.sortOrder}
onChange={(event) =>
change(
item.id,
"sortOrder",
Number(event.target.value || 1),
)
}
className={fieldClassName}
/>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
);
}
"use client";
import { ImagePlus, Save } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import {
Dialog,
......@@ -15,7 +15,11 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { fieldClassName, type ConfigItemForm, type ConfigItemMode } from "./types";
import {
fieldClassName,
type ConfigItemForm,
type ConfigItemMode,
} from "./types";
export function ConfigItemDialog({
open,
......@@ -114,7 +118,7 @@ export function ConfigItemDialog({
<div className="overflow-hidden rounded-3xl border border-dashed border-[#063e8e]/20 bg-[#eef4ff]/60">
<div className="relative aspect-[16/9]">
{previewMedia ? (
<SafeImage
<Image
src={previewMedia.url}
alt={previewMedia.alt || previewMedia.name}
fill
......
......@@ -5,13 +5,6 @@ export const fieldClassName =
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
export type ConfigItemMode = "logo" | "banner";
export type ApiEnvelope<T> = {
responseData?: T;
data?: {
responseData?: T;
};
};
export type LogoMediaItem = AdminMediaItem & {
logoId?: string;
};
......
import type {
Banner,
BannerMutate,
Logo,
SiteInformationBranch,
SiteInformationBranchMutate,
SiteInformationData,
SiteInformationSocialLink,
SiteInformationSocialMutate,
} from "@/api/vcci-news/models";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import links from "@/links";
import {
type BaseConfigBannerItem,
type BaseConfigBranchItem,
type BaseConfigData,
type BaseConfigLogoItem,
type BaseConfigSocialItem,
createBaseConfigItemId,
} from "@/mockdata/base-config";
import type {
ApiEnvelope,
ConfigItemForm,
LogoMediaItem,
} from "./types";
export function emptyItemForm(): ConfigItemForm {
return {
name: "",
imageId: "",
isActive: true,
displayTimeSeconds: 5,
sortOrder: 1,
};
}
export function resolveMediaItem(
mediaMap: Map<string, AdminMediaItem>,
imageId: string,
) {
return mediaMap.get(imageId) ?? null;
}
export function getEnvelopeData<T>(payload: unknown): T | undefined {
const root = payload as ApiEnvelope<T>;
return root.responseData ?? root.data?.responseData;
}
export function mapApiBranchToConfig(
branch: SiteInformationBranch,
): BaseConfigBranchItem {
return {
id: branch.id ?? createBaseConfigItemId("branch"),
branchName: branch.branch_name ?? "",
address: branch.address ?? "",
hotline: branch.hotline ?? branch.telephone ?? "",
email: branch.email ?? "",
fax: branch.fax ?? "",
mapsEmbedUrl: branch.googlemap_link ?? "",
sortOrder: branch.sort_order ?? 1,
isVisible: branch.is_active ?? true,
};
}
export function mapConfigBranchToApi(
branch: BaseConfigBranchItem,
index: number,
): SiteInformationBranchMutate {
return {
branch_name: branch.branchName.trim() || null,
address: branch.address.trim() || null,
hotline: branch.hotline.trim() || null,
email: branch.email.trim() || null,
fax: branch.fax.trim() || null,
googlemap_link: branch.mapsEmbedUrl.trim() || null,
sort_order: Number.isFinite(branch.sortOrder)
? branch.sortOrder
: index + 1,
is_active: branch.isVisible,
};
}
export function mapApiSocialToConfig(
social: SiteInformationSocialLink,
): BaseConfigSocialItem {
return {
id: social.id,
label: social.label,
url: social.url ?? "",
isVisible: social.is_active,
sortOrder: social.sort_order,
};
}
export function mapConfigSocialToApi(
social: BaseConfigSocialItem,
): SiteInformationSocialMutate {
return {
url: social.url.trim() || null,
sort_order: social.sortOrder,
is_active: social.isVisible,
};
}
export function mapApiBannerToConfig(banner: Banner): BaseConfigBannerItem {
return {
id: banner.id ?? createBaseConfigItemId("banner"),
name: banner.banner_name ?? "",
imageId: banner.file_id ?? "",
isActive: banner.status !== "INACTIVE",
displayTimeSeconds: banner.display_time ?? 5,
sortOrder: banner.display_order ?? 1,
};
}
export function mapConfigBannerToApi(banner: BaseConfigBannerItem): BannerMutate {
return {
banner_name: banner.name.trim(),
file_id: banner.imageId,
display_order: banner.sortOrder,
display_time: Math.max(1, banner.displayTimeSeconds || 1),
status: banner.isActive ? "ACTIVE" : "INACTIVE",
};
}
export function mapApiLogoToConfig(logo: Logo): {
logo: BaseConfigLogoItem | null;
media: LogoMediaItem | null;
} | null {
const media: LogoMediaItem = {
id: logo.file_id,
logoId: logo.id,
name: logo.logo_name,
alt: logo.logo_name,
url: links.resolveImageUrl(logo.logo_url) || "/img-error.png",
mime: "image/*",
size: 0,
created_at: logo.created_at,
updated_at: logo.updated_at,
source: "upload",
};
return {
logo: {
id: logo.id,
name: logo.logo_name,
imageId: logo.file_id,
isActive: true,
},
media,
};
}
export function applySiteInformationToConfig(
baseConfig: BaseConfigData,
siteInformation: SiteInformationData,
logo?: Logo | null,
): BaseConfigData {
const logoConfig = logo ? mapApiLogoToConfig(logo) : null;
return {
...baseConfig,
logo: logoConfig?.logo ?? baseConfig.logo,
websiteName: siteInformation.website_name ?? baseConfig.websiteName,
websiteLink: siteInformation.website_link ?? baseConfig.websiteLink,
branches: Array.isArray(siteInformation.branches)
? siteInformation.branches.map(mapApiBranchToConfig)
: baseConfig.branches,
socials: Array.isArray(siteInformation.socials)
? siteInformation.socials.map(mapApiSocialToConfig)
: baseConfig.socials,
};
}
This diff is collapsed.
......@@ -2,9 +2,9 @@
import * as React from "react";
import { Image as ImageIcon, Pencil, Plus, Type, Upload, X } from "lucide-react";
import Image from "next/image";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminRichTextEditor } from "@/components/shared/rich-text-editor";
import { SafeImage } from "@/components/shared/safe-image";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
......@@ -409,7 +409,7 @@ export function AdminPostContentEditor({
}}
className="relative h-64 w-full cursor-pointer"
>
<SafeImage
<Image
src={currentImage.image.url}
alt={currentImage.image.alt || currentImage.image.name}
fill
......
......@@ -200,7 +200,7 @@ function Footer() {
return (
<footer className="w-full bg-[#202f67] text-white">
<div className="container mx-auto px-5 py-10 sm:px-6 lg:px-10 lg:py-12">
<div className="container mx-auto px-5 py-10 sm:px-6 lg:px-10 lg:pt-12 lg:pb-6">
<div className="grid gap-10 lg:grid-cols-[1.15fr_1.05fr_0.9fr]">
<div>
<h2 className="client-footer-title uppercase">
......@@ -257,8 +257,8 @@ function Footer() {
{message ? (
<div
className={`mt-3 flex items-center gap-2 rounded-[6px] px-4 py-3 text-[13px] font-medium ${messageType === "success"
? "bg-[#1a8754] text-white"
: "bg-[#c0392b] text-white"
? "bg-[#1a8754] text-white"
: "bg-[#c0392b] text-white"
}`}
>
<span className="text-base leading-none">
......
import type { DynamicPostContentSection } from "@/api/vcci-news/types/post";
import Image from "next/image";
import { ImageLightbox } from "../../image-lightbox";
import { SafeImage } from "../../safe-image";
import { useState } from "react";
function getGridClassName(columns: number) {
......@@ -39,7 +39,7 @@ export function ImageSection({ section }: { section: DynamicPostContentSection }
})
}
>
<SafeImage
<Image
src={image.url}
alt={image.alt || image.name || "Hình ảnh bài viết"}
width={1200}
......
"use client";
import Image, { type ImageProps } from "next/image";
import { useEffect, useState } from "react";
interface SafeImageProps extends Omit<ImageProps, "src"> {
src?: string | null;
fallbackSrc?: string;
}
export function SafeImage({
src,
alt,
fallbackSrc = "/img-error.png",
...props
}: SafeImageProps) {
const [currentSrc, setCurrentSrc] = useState(src || fallbackSrc);
const [hasFailed, setHasFailed] = useState(false);
useEffect(() => {
setCurrentSrc(src || fallbackSrc);
setHasFailed(false);
}, [fallbackSrc, src]);
return (
<Image
{...props}
src={currentSrc}
alt={alt}
onError={() => {
if (hasFailed || currentSrc === fallbackSrc) return;
setHasFailed(true);
setCurrentSrc(fallbackSrc);
}}
unoptimized
/>
);
}
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