feat: trang trưng bày cổ vật 3D + nâng cấp typography/độ tương phản

- Trình xem mô hình .glb (three GLTFLoader + OrbitControls): xoay/zoom/tự xoay,
  môi trường studio, trạng thái tải & lỗi; trang /co-vat với 2 cổ vật chuông đồng
- Đổi tên file .glb Unicode -> ASCII (chuong-1/2.glb) tránh lỗi đường dẫn
- Thêm font serif hiển thị (Noto Serif Display) cho tiêu đề
- Sửa độ tương phản chữ trên bề mặt sáng (header, ô tìm kiếm, bộ lọc), badge vuông

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
tuyenio
2026-06-23 14:45:41 +07:00
parent 17a7866cd2
commit f518c07763
10 changed files with 507 additions and 19 deletions
Binary file not shown.
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import Image from "next/image";
import { Boxes, ScanLine, Sparkles } from "lucide-react";
import { AntiquityShowcase } from "@/app/components/antiquity/AntiquityShowcase";
import { BackToTopButton } from "@/app/components/landing/BackToTopButton";
import { SiteFooter } from "@/app/components/landing/SiteFooter";
import { SiteHeader } from "@/app/components/landing/SiteHeader";
import { activeTour, antiquities } from "@/app/content";
export const metadata: Metadata = {
title: "Cổ vật 3D | VR360 Như Quỳnh",
description:
"Phòng trưng bày số các cổ vật, hiện vật tiêu biểu của hệ thống di tích Xã Như Quỳnh được số hóa thành mô hình 3D xem được mọi góc.",
};
const highlights = [
{ icon: ScanLine, title: "Số hóa 3D độ chi tiết cao", detail: "Hiện vật được quét và dựng mô hình ba chiều, lưu giữ hình khối và hoa văn nguyên bản." },
{ icon: Boxes, title: "Xem mọi góc, mọi lúc", detail: "Xoay, phóng to và quan sát từng chi tiết ngay trên trình duyệt, không cần tiếp xúc hiện vật gốc." },
{ icon: Sparkles, title: "Bảo tồn cho mai sau", detail: "Tư liệu số phục vụ trưng bày, giáo dục và nghiên cứu di sản lâu dài." },
];
export default function AntiquityPage() {
return (
<main className="public-page min-h-[100dvh] bg-[var(--background)] text-[var(--foreground)]">
<SiteHeader />
<section className="public-subpage-hero public-subpage-hero--compact relative isolate overflow-hidden border-b border-[var(--surface-border)]">
<Image src={activeTour.shrineImage} alt="" fill priority sizes="100vw" className="public-subpage-hero-image object-cover" />
<div className="public-subpage-hero-content mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22">
<div className="public-rise public-hero-rule max-w-4xl">
<p className="public-kicker">Phòng trưng bày số</p>
<h1 className="public-gradient-text public-heading-safe mt-4 text-[clamp(2.55rem,5.8vw,5rem)] font-extrabold">
Cổ vật & hiện vật 3D
</h1>
<p className="public-subpage-copy mt-6 max-w-[60ch] text-base leading-8 text-[var(--foreground)]/82 sm:text-lg">
Bộ sưu tập số các cổ vật tiêu biểu thuộc hệ thống di tích Như Quỳnh. Mỗi hiện vật đưc
dựng thành hình ba chiều đ chiêm ngưỡng trọn vẹn từ mọi góc nhìn.
</p>
</div>
</div>
</section>
<AntiquityShowcase items={antiquities} />
<section className="border-t border-[var(--surface-border)] bg-[var(--surface-band)]">
<div className="mx-auto grid max-w-7xl gap-4 px-4 py-14 sm:px-6 md:grid-cols-3 lg:py-18">
{highlights.map((item) => {
const Icon = item.icon;
return (
<article key={item.title} className="public-panel public-stagger-item rounded-[14px] p-6">
<Icon className="h-7 w-7 text-[var(--primary)]" strokeWidth={1.7} />
<h3 className="mt-4 text-lg font-bold text-[var(--tour-ink)]">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-[var(--muted-foreground)]">{item.detail}</p>
</article>
);
})}
</div>
</section>
<section className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:py-16">
<div className="public-panel public-glow rounded-[16px] p-6 text-center sm:p-9">
<p className="public-kicker justify-center">Mở rộng bộ sưu tập</p>
<h2 className="public-display mt-3 text-2xl font-bold text-[var(--tour-ink)] sm:text-3xl">
Hiện vật từ các di tích khác đang đưc số hóa
</h2>
<p className="mx-auto mt-3 max-w-[64ch] text-sm leading-7 text-[var(--foreground)]/76 sm:text-base">
Phòng trưng bày sẽ tiếp tục bổ sung các cổ vật, sắc phong, bia đá đ thờ tự tiêu biểu
của toàn bộ hệ thống di tích Như Quỳnh trong giai đoạn 20262030.
</p>
</div>
</section>
<SiteFooter />
<BackToTopButton />
</main>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import { CalendarClock, Layers, Landmark } from "lucide-react";
import { AntiquityViewer } from "./AntiquityViewer";
import type { Antiquity } from "@/app/content";
export function AntiquityShowcase({ items }: { items: Antiquity[] }) {
const [activeId, setActiveId] = useState(items[0]?.id);
const active = items.find((item) => item.id === activeId) ?? items[0];
if (!active) {
return (
<section className="mx-auto max-w-3xl px-4 py-20 text-center sm:px-6">
<p className="text-base text-[var(--muted-foreground)]">Bộ sưu tập cổ vật số đang đưc cập nhật.</p>
</section>
);
}
return (
<section className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:py-16">
<div className="grid gap-6 lg:grid-cols-[1.32fr_0.68fr] lg:items-stretch">
<div className="public-panel public-glow rounded-[16px] p-3 sm:p-4">
<div className="h-[clamp(360px,58vh,640px)] w-full">
{/* key buộc viewer dựng lại sạch khi đổi hiện vật */}
<AntiquityViewer key={active.model} src={active.model} />
</div>
</div>
<div className="public-panel flex flex-col rounded-[16px] p-6 sm:p-7">
<p className="public-kicker">Hiện vật đang xem</p>
<h2 className="public-display public-heading-safe mt-3 text-[clamp(1.7rem,3vw,2.3rem)] font-bold text-[var(--tour-ink)]">
{active.name}
</h2>
<dl className="mt-5 grid gap-2.5">
<Meta icon={Landmark} label="Di tích" value={active.site} />
<Meta icon={Layers} label="Chất liệu" value={active.material} />
<Meta icon={CalendarClock} label="Niên đại" value={active.period} />
</dl>
<div className="mt-5 grid gap-3 border-t border-[var(--surface-border)] pt-5 text-sm leading-7 text-[var(--foreground)]/82">
{active.description.map((text, index) => (
<p key={index}>{text}</p>
))}
</div>
</div>
</div>
<div className="mt-8">
<p className="text-sm font-bold text-[var(--muted-foreground)]">
Bộ sưu tập · {items.length} hiện vật đã số hóa 3D
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, index) => {
const isActive = item.id === active.id;
return (
<button
key={item.id}
type="button"
onClick={() => setActiveId(item.id)}
aria-pressed={isActive}
className={`group flex items-center gap-4 rounded-[14px] border p-4 text-left transition active:scale-[0.99] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[rgb(200_164_106_/_0.22)] ${
isActive
? "border-[var(--primary)] bg-[rgb(200_164_106_/_0.12)] shadow-[0_14px_40px_rgb(0_0_0_/_0.3)]"
: "border-[var(--surface-border)] bg-[var(--surface-glass)] hover:border-[rgb(200_164_106_/_0.45)]"
}`}
>
<span
className={`grid h-12 w-12 shrink-0 place-items-center rounded-[10px] font-display-vn text-lg font-bold transition ${
isActive
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
: "bg-[rgb(200_164_106_/_0.14)] text-[var(--primary)]"
}`}
>
{String(index + 1).padStart(2, "0")}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-bold text-[var(--tour-ink)]">{item.name}</span>
<span className="mt-0.5 block text-xs text-[var(--muted-foreground)]">
{item.material} · {item.period}
</span>
</span>
</button>
);
})}
</div>
</div>
</section>
);
}
function Meta({
icon: Icon,
label,
value,
}: {
icon: typeof Landmark;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-[8px] bg-[rgb(200_164_106_/_0.14)]">
<Icon className="h-4 w-4 text-[var(--primary)]" strokeWidth={1.8} />
</span>
<span>
<span className="block text-xs font-bold uppercase tracking-[0.1em] text-[var(--muted-foreground)]">{label}</span>
<span className="block text-sm font-semibold text-[var(--tour-ink)]">{value}</span>
</span>
</div>
);
}
@@ -0,0 +1,223 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { Rotate3d, Pause, Play, Maximize2 } from "lucide-react";
type Status = "loading" | "ready" | "error";
/**
* Trình xem mô hình 3D (.glb) cho cổ vật: xoay, phóng to, tự xoay.
* Dùng three.js (đã có sẵn trong dự án) — không thêm thư viện mới.
*/
export function AntiquityViewer({ src }: { src: string }) {
const mountRef = useRef<HTMLDivElement>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const homeRef = useRef<{ pos: THREE.Vector3; target: THREE.Vector3 } | null>(null);
const autoRef = useRef(true);
const [status, setStatus] = useState<Status>("loading");
const [progress, setProgress] = useState(0);
const [autoRotate, setAutoRotate] = useState(true);
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
setStatus("loading");
setProgress(0);
let raf = 0;
let disposed = false;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
renderer.domElement.style.cssText = "width:100%;height:100%;display:block;cursor:grab;";
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 1000);
cameraRef.current = camera;
const pmrem = new THREE.PMREMGenerator(renderer);
const envTexture = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environment = envTexture;
const keyLight = new THREE.DirectionalLight(0xfff1d6, 2.4);
keyLight.position.set(2.5, 3.5, 2.2);
const rimLight = new THREE.DirectionalLight(0xcdd9ff, 1.2);
rimLight.position.set(-3, 1.8, -2.4);
const ambient = new THREE.AmbientLight(0xffffff, 0.3);
scene.add(keyLight, rimLight, ambient);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.autoRotate = autoRef.current;
controls.autoRotateSpeed = 1.1;
controls.addEventListener("start", () => {
renderer.domElement.style.cursor = "grabbing";
});
controls.addEventListener("end", () => {
renderer.domElement.style.cursor = "grab";
});
controlsRef.current = controls;
const resize = () => {
const w = mount.clientWidth;
const h = mount.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
const observer = new ResizeObserver(resize);
observer.observe(mount);
resize();
const loader = new GLTFLoader();
loader.load(
src,
(gltf) => {
if (disposed) return;
const model = gltf.scene;
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z) || 1;
model.scale.setScalar(2 / maxDim);
scene.add(model);
const radius = 1; // mô hình đã chuẩn hóa về ~2 đơn vị
const dist = (radius / Math.sin((camera.fov * Math.PI) / 360)) * 1.25;
camera.position.set(dist * 0.62, dist * 0.32, dist * 0.92);
controls.target.set(0, 0, 0);
controls.minDistance = dist * 0.55;
controls.maxDistance = dist * 2.6;
controls.update();
homeRef.current = { pos: camera.position.clone(), target: controls.target.clone() };
setStatus("ready");
},
(event) => {
if (event.total) setProgress(Math.round((event.loaded / event.total) * 100));
},
() => {
if (!disposed) setStatus("error");
},
);
const tick = () => {
controls.update();
renderer.render(scene, camera);
raf = requestAnimationFrame(tick);
};
tick();
return () => {
disposed = true;
cancelAnimationFrame(raf);
observer.disconnect();
controls.dispose();
scene.traverse((object) => {
const mesh = object as THREE.Mesh;
if (mesh.geometry) mesh.geometry.dispose();
const material = mesh.material;
if (Array.isArray(material)) material.forEach((m) => m.dispose());
else if (material) (material as THREE.Material).dispose();
});
envTexture.dispose();
pmrem.dispose();
renderer.dispose();
if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement);
};
}, [src]);
const toggleAutoRotate = () => {
const next = !autoRotate;
setAutoRotate(next);
autoRef.current = next;
if (controlsRef.current) controlsRef.current.autoRotate = next;
};
const resetView = () => {
const camera = cameraRef.current;
const controls = controlsRef.current;
const home = homeRef.current;
if (!camera || !controls || !home) return;
camera.position.copy(home.pos);
controls.target.copy(home.target);
controls.update();
};
return (
<div className="relative h-full w-full overflow-hidden rounded-[14px]">
<div
ref={mountRef}
className="h-full w-full"
style={{
background:
"radial-gradient(circle at 50% 36%, rgb(63 70 54 / 0.55), rgb(18 21 16 / 0.92) 72%)",
}}
/>
{status === "loading" ? (
<div className="absolute inset-0 grid place-items-center bg-[rgb(18_21_16_/_0.55)] backdrop-blur-[2px]">
<div className="flex flex-col items-center gap-3 text-center">
<span className="grid h-12 w-12 animate-spin place-items-center rounded-full border-2 border-[rgb(200_164_106_/_0.25)] border-t-[var(--primary)]" />
<p className="text-sm font-semibold text-[var(--tour-ink)]">Đang tải hình 3D {progress > 0 ? `${progress}%` : ""}</p>
<p className="max-w-[24ch] text-xs text-[var(--muted-foreground)]">Hiện vật đ phân giải cao, vui lòng đi trong giây lát.</p>
</div>
</div>
) : null}
{status === "error" ? (
<div className="absolute inset-0 grid place-items-center bg-[rgb(18_21_16_/_0.7)]">
<p className="max-w-[28ch] px-6 text-center text-sm font-semibold text-[var(--tour-ink)]">
Không tải đưc hình 3D. Vui lòng kiểm tra kết nối thử lại.
</p>
</div>
) : null}
{status === "ready" ? (
<>
<div className="pointer-events-none absolute left-1/2 top-4 -translate-x-1/2 rounded-full border border-[rgb(215_199_157_/_0.18)] bg-[rgb(18_21_16_/_0.55)] px-3.5 py-1.5 text-[0.72rem] font-medium text-[var(--tour-ink)] backdrop-blur-md">
Kéo đ xoay · Lăn chuột đ phóng to
</div>
<div className="absolute bottom-4 right-4 flex gap-2">
<button
type="button"
onClick={toggleAutoRotate}
aria-label={autoRotate ? "Tạm dừng tự xoay" : "Bật tự xoay"}
className="grid h-10 w-10 place-items-center rounded-full border border-[rgb(215_199_157_/_0.22)] bg-[rgb(18_21_16_/_0.6)] text-[var(--tour-ink)] backdrop-blur-md transition hover:border-[var(--primary)] hover:text-[var(--primary)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[rgb(200_164_106_/_0.25)]"
>
{autoRotate ? <Pause className="h-4 w-4" strokeWidth={1.8} /> : <Play className="h-4 w-4" strokeWidth={1.8} />}
</button>
<button
type="button"
onClick={resetView}
aria-label="Đặt lại góc nhìn"
className="grid h-10 w-10 place-items-center rounded-full border border-[rgb(215_199_157_/_0.22)] bg-[rgb(18_21_16_/_0.6)] text-[var(--tour-ink)] backdrop-blur-md transition hover:border-[var(--primary)] hover:text-[var(--primary)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[rgb(200_164_106_/_0.25)]"
>
<Maximize2 className="h-4 w-4" strokeWidth={1.8} />
</button>
</div>
<div className="pointer-events-none absolute bottom-4 left-4 inline-flex items-center gap-1.5 text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-[rgb(248_242_229_/_0.62)]">
<Rotate3d className="h-3.5 w-3.5" strokeWidth={1.8} />
hình 3D
</div>
</>
) : null}
</div>
);
}
@@ -50,7 +50,7 @@ export function CulturalSpaceExplorer({ sites }: { sites: HeritageSite[] }) {
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="Nhập tên di tích, loại địa điểm hoặc từ khóa" placeholder="Nhập tên di tích, loại địa điểm hoặc từ khóa"
className="h-12 w-full rounded-[8px] border border-[var(--surface-border)] bg-white/82 pl-11 pr-4 text-sm font-semibold text-[var(--tour-ink)] outline-none transition focus:border-[var(--primary)] focus:ring-4 focus:ring-[rgb(142_95_11_/_0.12)]" className="h-12 w-full rounded-[10px] border border-[var(--surface-border)] bg-white/85 pl-11 pr-4 text-sm font-semibold text-[var(--ink-on-light)] outline-none transition placeholder:text-[var(--ink-on-light-muted)] focus:border-[var(--primary)] focus:ring-4 focus:ring-[rgb(142_95_11_/_0.14)]"
/> />
</span> </span>
</label> </label>
@@ -110,12 +110,13 @@ export function CulturalSpaceExplorer({ sites }: { sites: HeritageSite[] }) {
</div> </div>
<div className="flex min-h-[19rem] flex-col p-5"> <div className="flex min-h-[19rem] flex-col p-5">
<span <span
className={`w-fit rounded-full border px-3 py-1 text-xs font-extrabold ${ className={`inline-flex w-fit items-center gap-1.5 rounded-md border px-2.5 py-1 text-[0.7rem] font-bold uppercase tracking-[0.08em] ${
site.isAvailable site.isAvailable
? "border-[rgb(14_52_39_/_0.18)] bg-[rgb(14_92_62_/_0.12)] text-[var(--tour-ink)]" ? "border-[rgb(141_146_118_/_0.4)] bg-[rgb(141_146_118_/_0.16)] text-[var(--tour-gold-light)]"
: "border-[rgb(142_95_11_/_0.18)] bg-[rgb(142_95_11_/_0.1)] text-[var(--primary)]" : "border-[rgb(200_164_106_/_0.28)] bg-[rgb(200_164_106_/_0.1)] text-[var(--primary)]"
}`} }`}
> >
<span className={`h-1.5 w-1.5 rounded-full ${site.isAvailable ? "bg-[var(--tour-jade)]" : "bg-[var(--primary)]"}`} />
{site.status} {site.status}
</span> </span>
<p className="mt-4 text-sm font-bold text-[var(--primary)]">{site.type}</p> <p className="mt-4 text-sm font-bold text-[var(--primary)]">{site.type}</p>
@@ -165,10 +166,10 @@ function FilterGroup<T extends readonly string[]>({
key={option} key={option}
type="button" type="button"
onClick={() => onChange(option)} onClick={() => onChange(option)}
className={`h-12 shrink-0 rounded-full border px-4 text-sm font-extrabold transition active:scale-[0.98] ${ className={`h-12 shrink-0 rounded-[10px] border px-4 text-sm font-bold transition active:scale-[0.98] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[rgb(142_95_11_/_0.18)] ${
value === option value === option
? "border-[var(--primary)] bg-[var(--tour-ink)] text-[var(--primary-foreground)]" ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)] shadow-[0_8px_20px_rgb(142_95_11_/_0.28)]"
: "border-[var(--surface-border)] bg-white/76 text-[var(--tour-ink)] hover:border-[var(--primary)]/40" : "border-[var(--surface-border)] bg-white/72 text-[var(--ink-on-light-muted)] hover:border-[rgb(142_95_11_/_0.4)] hover:text-[var(--ink-on-light)]"
}`} }`}
> >
{option} {option}
+8 -8
View File
@@ -22,10 +22,10 @@ export function SiteHeader() {
<Landmark className="h-5 w-5 text-[var(--primary)]" strokeWidth={1.8} /> <Landmark className="h-5 w-5 text-[var(--primary)]" strokeWidth={1.8} />
</span> </span>
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-sm font-bold leading-tight text-[var(--tour-ink)] sm:text-base"> <span className="public-display block truncate text-base font-bold leading-tight text-[var(--ink-on-light)] sm:text-lg">
VR360 Như Quỳnh VR360 Như Quỳnh
</span> </span>
<span className="hidden truncate text-xs leading-5 text-[var(--muted-foreground)] sm:block"> <span className="hidden truncate text-xs leading-5 text-[var(--ink-on-light-muted)] sm:block">
Di sản văn hóa Như Quỳnh Di sản văn hóa Như Quỳnh
</span> </span>
</span> </span>
@@ -39,8 +39,8 @@ export function SiteHeader() {
key={item.href} key={item.href}
className={`public-nav-link text-sm font-semibold transition-colors ${ className={`public-nav-link text-sm font-semibold transition-colors ${
isActive isActive
? "text-[var(--primary)]" ? "font-bold text-[var(--ink-on-light)]"
: "text-[var(--muted-foreground)] hover:text-[var(--tour-ink)]" : "text-[var(--ink-on-light-muted)] hover:text-[var(--ink-on-light)]"
}`} }`}
href={item.href} href={item.href}
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
@@ -62,7 +62,7 @@ export function SiteHeader() {
<button <button
onClick={() => setMobileMenuOpen((open) => !open)} onClick={() => setMobileMenuOpen((open) => !open)}
className="grid h-10 w-10 place-items-center rounded-[8px] border border-[var(--surface-border)] bg-[rgb(255_255_255_/_0.8)] text-[var(--tour-ink)] shadow-[0_10px_28px_rgb(37_75_54_/_0.08)] transition-transform active:scale-[0.98] lg:hidden" className="grid h-10 w-10 place-items-center rounded-[8px] border border-[var(--surface-border)] bg-[rgb(255_255_255_/_0.8)] text-[var(--ink-on-light)] shadow-[0_10px_28px_rgb(37_75_54_/_0.08)] transition-transform active:scale-[0.98] lg:hidden"
aria-label="Menu" aria-label="Menu"
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
> >
@@ -84,10 +84,10 @@ export function SiteHeader() {
key={item.href} key={item.href}
href={item.href} href={item.href}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
className={`rounded-[8px] border px-4 py-3 text-sm font-medium transition-colors ${ className={`rounded-[10px] border px-4 py-3 text-sm font-semibold transition-colors ${
isActive isActive
? "border-[var(--primary)]/35 bg-[var(--surface-glass-strong)] text-[var(--primary)]" ? "border-[rgb(142_95_11_/_0.35)] bg-[rgb(200_164_106_/_0.18)] text-[var(--ink-on-light)]"
: "border-[var(--surface-border)] bg-[var(--surface-glass)] text-[var(--tour-ink)] hover:bg-[var(--surface-glass-strong)]" : "border-[rgb(28_36_25_/_0.1)] bg-[rgb(255_255_255_/_0.5)] text-[var(--ink-on-light-muted)] hover:text-[var(--ink-on-light)]"
}`} }`}
> >
{item.label} {item.label}
+45
View File
@@ -25,9 +25,54 @@ export const navigation = [
{ href: "/", label: "Trang chủ" }, { href: "/", label: "Trang chủ" },
{ href: "/gioi-thieu", label: "Giới thiệu" }, { href: "/gioi-thieu", label: "Giới thiệu" },
{ href: "/di-tich", label: "Di tích" }, { href: "/di-tich", label: "Di tích" },
{ href: "/co-vat", label: "Cổ vật 3D" },
{ href: "/lien-he", label: "Liên hệ" }, { href: "/lien-he", label: "Liên hệ" },
]; ];
export type Antiquity = {
id: string;
name: string;
site: string; // di tích lưu giữ
material: string;
period: string; // niên đại
model: string; // đường dẫn file .glb
description: string[];
isAvailable: boolean; // đã có mô hình 3D
};
// Bộ sưu tập cổ vật số hóa 3D. Hiện có 2 hiện vật đã dựng mô hình .glb;
// các trường niên đại/di tích để "Đang cập nhật" cho tới khi có dữ liệu xác thực.
export const antiquities: Antiquity[] = [
{
id: "chuong-dong-1",
name: "Chuông đồng — Hiện vật I",
site: "Cụm di tích Xã Như Quỳnh",
material: "Đồng",
period: "Đang cập nhật",
model: "/antiquity/chuong-1.glb",
description: [
"Chuông đồng là pháp khí quan trọng trong không gian thờ tự của các ngôi chùa Việt, dùng để báo hiệu thời khắc hành lễ và gắn bó với đời sống tâm linh của cộng đồng.",
"Hiện vật đã được số hóa thành mô hình 3D, cho phép xoay và phóng to để quan sát hình khối, hoa văn và minh văn từ mọi góc nhìn.",
"Thông tin chi tiết về niên đại, minh văn và di tích lưu giữ đang được bổ sung.",
],
isAvailable: true,
},
{
id: "chuong-dong-2",
name: "Chuông đồng — Hiện vật II",
site: "Cụm di tích Xã Như Quỳnh",
material: "Đồng",
period: "Đang cập nhật",
model: "/antiquity/chuong-2.glb",
description: [
"Một hiện vật chuông đồng khác trong kho di sản của Xã Như Quỳnh, mang những đặc điểm tạo hình và trang trí riêng.",
"Mô hình 3D giúp người xem chiêm ngưỡng từng chi tiết quai chuông, thân chuông và họa tiết trang trí mà không cần tiếp xúc trực tiếp với hiện vật gốc.",
"Niên đại và nguồn gốc chi tiết đang được nghiên cứu, bổ sung.",
],
isAvailable: true,
},
];
export type HeritageLevel = "Cấp quốc gia" | "Cấp tỉnh"; export type HeritageLevel = "Cấp quốc gia" | "Cấp tỉnh";
export type HeritageSite = { export type HeritageSite = {
+22 -1
View File
@@ -22,8 +22,10 @@
--font-sans: var(--font-noto-sans), system-ui, sans-serif; --font-sans: var(--font-noto-sans), system-ui, sans-serif;
--secondary: rgb(244 238 222 / 0.12); --secondary: rgb(244 238 222 / 0.12);
--background: #121510; --background: #121510;
--font-serif: var(--font-noto-sans), system-ui, sans-serif; --font-serif: var(--font-noto-serif), Georgia, "Times New Roman", serif;
--foreground: #f0eadc; --foreground: #f0eadc;
--ink-on-light: #1c2419;
--ink-on-light-muted: #51604a;
--destructive: #d85a42; --destructive: #d85a42;
--shadow-blur: 5px; --shadow-blur: 5px;
--shadow-color: hsl(95 16% 8%); --shadow-color: hsl(95 16% 8%);
@@ -257,6 +259,25 @@ a {
word-break: normal; word-break: normal;
} }
/* Tiêu đề hiển thị dùng serif trang trọng + tracking âm để có sức nặng thị giác. */
.public-page h1,
.public-page h2 {
font-family: var(--font-serif);
letter-spacing: -0.018em;
font-feature-settings: "liga" 1, "kern" 1;
}
.public-display {
font-family: var(--font-serif);
letter-spacing: -0.02em;
}
.public-display-italic {
font-family: var(--font-serif);
font-style: italic;
font-weight: 500;
}
.public-page p, .public-page p,
.public-page dd { .public-page dd {
overflow-wrap: break-word; overflow-wrap: break-word;
+10 -2
View File
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Noto_Sans } from "next/font/google"; import { Noto_Sans, Noto_Serif_Display } from "next/font/google";
import "./globals.css"; import "./globals.css";
const notoSans = Noto_Sans({ const notoSans = Noto_Sans({
@@ -8,6 +8,14 @@ const notoSans = Noto_Sans({
weight: ["400", "500", "600", "700", "800"], weight: ["400", "500", "600", "700", "800"],
}); });
// Serif hiển thị cho tiêu đề — tăng tính trang trọng, phù hợp di sản; hỗ trợ tiếng Việt đầy đủ.
const notoSerifDisplay = Noto_Serif_Display({
subsets: ["latin", "vietnamese"],
variable: "--font-noto-serif",
weight: ["500", "600", "700"],
style: ["normal", "italic"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "VR360 Di sản Xã Như Quỳnh", title: "VR360 Di sản Xã Như Quỳnh",
description: description:
@@ -25,7 +33,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="vi" className={`${notoSans.variable} h-full antialiased`}> <html lang="vi" className={`${notoSans.variable} ${notoSerifDisplay.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">{children}</body>
</html> </html>
); );