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:
Binary file not shown.
Binary file not shown.
@@ -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 Xã Như Quỳnh. Mỗi hiện vật được
|
||||
dựng thành mô 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 đá và đồ thờ tự tiêu biểu
|
||||
của toàn bộ hệ thống di tích Xã Như Quỳnh trong giai đoạn 2026–2030.
|
||||
</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 mô 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 mô hình 3D. Vui lòng kiểm tra kết nối và 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} />
|
||||
Mô hình 3D
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function CulturalSpaceExplorer({ sites }: { sites: HeritageSite[] }) {
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
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>
|
||||
</label>
|
||||
@@ -110,12 +110,13 @@ export function CulturalSpaceExplorer({ sites }: { sites: HeritageSite[] }) {
|
||||
</div>
|
||||
<div className="flex min-h-[19rem] flex-col p-5">
|
||||
<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
|
||||
? "border-[rgb(14_52_39_/_0.18)] bg-[rgb(14_92_62_/_0.12)] text-[var(--tour-ink)]"
|
||||
: "border-[rgb(142_95_11_/_0.18)] bg-[rgb(142_95_11_/_0.1)] text-[var(--primary)]"
|
||||
? "border-[rgb(141_146_118_/_0.4)] bg-[rgb(141_146_118_/_0.16)] text-[var(--tour-gold-light)]"
|
||||
: "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}
|
||||
</span>
|
||||
<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}
|
||||
type="button"
|
||||
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
|
||||
? "border-[var(--primary)] bg-[var(--tour-ink)] text-[var(--primary-foreground)]"
|
||||
: "border-[var(--surface-border)] bg-white/76 text-[var(--tour-ink)] hover:border-[var(--primary)]/40"
|
||||
? "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/72 text-[var(--ink-on-light-muted)] hover:border-[rgb(142_95_11_/_0.4)] hover:text-[var(--ink-on-light)]"
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
|
||||
@@ -22,10 +22,10 @@ export function SiteHeader() {
|
||||
<Landmark className="h-5 w-5 text-[var(--primary)]" strokeWidth={1.8} />
|
||||
</span>
|
||||
<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
|
||||
</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 Xã Như Quỳnh
|
||||
</span>
|
||||
</span>
|
||||
@@ -39,8 +39,8 @@ export function SiteHeader() {
|
||||
key={item.href}
|
||||
className={`public-nav-link text-sm font-semibold transition-colors ${
|
||||
isActive
|
||||
? "text-[var(--primary)]"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--tour-ink)]"
|
||||
? "font-bold text-[var(--ink-on-light)]"
|
||||
: "text-[var(--ink-on-light-muted)] hover:text-[var(--ink-on-light)]"
|
||||
}`}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
@@ -62,7 +62,7 @@ export function SiteHeader() {
|
||||
|
||||
<button
|
||||
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-expanded={mobileMenuOpen}
|
||||
>
|
||||
@@ -84,10 +84,10 @@ export function SiteHeader() {
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
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
|
||||
? "border-[var(--primary)]/35 bg-[var(--surface-glass-strong)] text-[var(--primary)]"
|
||||
: "border-[var(--surface-border)] bg-[var(--surface-glass)] text-[var(--tour-ink)] hover:bg-[var(--surface-glass-strong)]"
|
||||
? "border-[rgb(142_95_11_/_0.35)] bg-[rgb(200_164_106_/_0.18)] text-[var(--ink-on-light)]"
|
||||
: "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}
|
||||
|
||||
@@ -25,9 +25,54 @@ export const navigation = [
|
||||
{ href: "/", label: "Trang chủ" },
|
||||
{ href: "/gioi-thieu", label: "Giới thiệu" },
|
||||
{ href: "/di-tich", label: "Di tích" },
|
||||
{ href: "/co-vat", label: "Cổ vật 3D" },
|
||||
{ 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 HeritageSite = {
|
||||
|
||||
+22
-1
@@ -22,8 +22,10 @@
|
||||
--font-sans: var(--font-noto-sans), system-ui, sans-serif;
|
||||
--secondary: rgb(244 238 222 / 0.12);
|
||||
--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;
|
||||
--ink-on-light: #1c2419;
|
||||
--ink-on-light-muted: #51604a;
|
||||
--destructive: #d85a42;
|
||||
--shadow-blur: 5px;
|
||||
--shadow-color: hsl(95 16% 8%);
|
||||
@@ -257,6 +259,25 @@ a {
|
||||
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 dd {
|
||||
overflow-wrap: break-word;
|
||||
|
||||
+10
-2
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
const notoSans = Noto_Sans({
|
||||
@@ -8,6 +8,14 @@ const notoSans = Noto_Sans({
|
||||
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 = {
|
||||
title: "VR360 Di sản Xã Như Quỳnh",
|
||||
description:
|
||||
@@ -25,7 +33,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
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>
|
||||
</html>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user