diff --git a/public/antiquity/chuong-1.glb b/public/antiquity/chuong-1.glb new file mode 100644 index 0000000..c2f07b4 Binary files /dev/null and b/public/antiquity/chuong-1.glb differ diff --git a/public/antiquity/chuong-2.glb b/public/antiquity/chuong-2.glb new file mode 100644 index 0000000..ca5c917 Binary files /dev/null and b/public/antiquity/chuong-2.glb differ diff --git a/src/app/co-vat/page.tsx b/src/app/co-vat/page.tsx new file mode 100644 index 0000000..469630b --- /dev/null +++ b/src/app/co-vat/page.tsx @@ -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 ( +
+ + +
+ +
+
+

Phòng trưng bày số

+

+ Cổ vật & hiện vật 3D +

+

+ 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. +

+
+
+
+ + + +
+
+ {highlights.map((item) => { + const Icon = item.icon; + return ( +
+ +

{item.title}

+

{item.detail}

+
+ ); + })} +
+
+ +
+
+

Mở rộng bộ sưu tập

+

+ Hiện vật từ các di tích khác đang được số hóa +

+

+ 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. +

+
+
+ + + +
+ ); +} diff --git a/src/app/components/antiquity/AntiquityShowcase.tsx b/src/app/components/antiquity/AntiquityShowcase.tsx new file mode 100644 index 0000000..976e43b --- /dev/null +++ b/src/app/components/antiquity/AntiquityShowcase.tsx @@ -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 ( +
+

Bộ sưu tập cổ vật số đang được cập nhật.

+
+ ); + } + + return ( +
+
+
+
+ {/* key buộc viewer dựng lại sạch khi đổi hiện vật */} + +
+
+ +
+

Hiện vật đang xem

+

+ {active.name} +

+ +
+ + + +
+ +
+ {active.description.map((text, index) => ( +

{text}

+ ))} +
+
+
+ +
+

+ Bộ sưu tập · {items.length} hiện vật đã số hóa 3D +

+
+ {items.map((item, index) => { + const isActive = item.id === active.id; + return ( + + ); + })} +
+
+
+ ); +} + +function Meta({ + icon: Icon, + label, + value, +}: { + icon: typeof Landmark; + label: string; + value: string; +}) { + return ( +
+ + + + + {label} + {value} + +
+ ); +} diff --git a/src/app/components/antiquity/AntiquityViewer.tsx b/src/app/components/antiquity/AntiquityViewer.tsx new file mode 100644 index 0000000..09f9a85 --- /dev/null +++ b/src/app/components/antiquity/AntiquityViewer.tsx @@ -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(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const homeRef = useRef<{ pos: THREE.Vector3; target: THREE.Vector3 } | null>(null); + const autoRef = useRef(true); + + const [status, setStatus] = useState("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 ( +
+
+ + {status === "loading" ? ( +
+
+ +

Đang tải mô hình 3D… {progress > 0 ? `${progress}%` : ""}

+

Hiện vật độ phân giải cao, vui lòng đợi trong giây lát.

+
+
+ ) : null} + + {status === "error" ? ( +
+

+ Không tải được mô hình 3D. Vui lòng kiểm tra kết nối và thử lại. +

+
+ ) : null} + + {status === "ready" ? ( + <> +
+ Kéo để xoay · Lăn chuột để phóng to +
+
+ + +
+
+ + Mô hình 3D +
+ + ) : null} +
+ ); +} diff --git a/src/app/components/landing/CulturalSpaceExplorer.tsx b/src/app/components/landing/CulturalSpaceExplorer.tsx index acdc9dd..26fe998 100644 --- a/src/app/components/landing/CulturalSpaceExplorer.tsx +++ b/src/app/components/landing/CulturalSpaceExplorer.tsx @@ -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)]" /> @@ -110,12 +110,13 @@ export function CulturalSpaceExplorer({ sites }: { sites: HeritageSite[] }) {
+ {site.status}

{site.type}

@@ -165,10 +166,10 @@ function FilterGroup({ 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} diff --git a/src/app/components/landing/SiteHeader.tsx b/src/app/components/landing/SiteHeader.tsx index 5b7ae06..fb32bb8 100644 --- a/src/app/components/landing/SiteHeader.tsx +++ b/src/app/components/landing/SiteHeader.tsx @@ -1,4 +1,4 @@ -"use client"; + "use client"; import Link from "next/link"; import { Landmark, Menu, PlayCircle, X } from "lucide-react"; @@ -22,10 +22,10 @@ export function SiteHeader() {
- + VR360 Như Quỳnh - + Di sản văn hóa Xã Như Quỳnh @@ -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() {