merge: tối ưu hiệu năng web (hero/render-gate, nén ảnh+model, LRU, CSS, bundle)
@@ -44,3 +44,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# ảnh gốc trước khi nén (backup cục bộ)
|
# ảnh gốc trước khi nén (backup cục bộ)
|
||||||
/image-originals
|
/image-originals
|
||||||
|
|
||||||
|
# model .glb gốc trước khi nén (backup cục bộ)
|
||||||
|
/model-originals
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
images: {
|
||||||
|
formats: ["image/avif", "image/webp"],
|
||||||
|
deviceSizes: [360, 640, 768, 1024, 1280, 1536, 1920],
|
||||||
|
minimumCacheTTL: 2678400,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"optimize:images": "node scripts/optimize-images.mjs",
|
||||||
|
"optimize:models": "node scripts/optimize-models.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paper-design/shaders-react": "^0.0.76",
|
"@paper-design/shaders-react": "^0.0.76",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"three": "^0.184.0"
|
"three": "^0.184.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@gltf-transform/cli": "^4.4.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 799 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 994 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 863 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 964 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 817 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 992 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 810 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 839 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 949 KiB |
|
After Width: | Height: | Size: 565 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 623 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 892 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 912 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 999 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 906 KiB |
|
After Width: | Height: | Size: 531 KiB |
@@ -10,7 +10,8 @@ const MODAL_DIR = path.join(ROOT, "public/modal");
|
|||||||
const BACKUP_DIR = path.join(ROOT, "image-originals");
|
const BACKUP_DIR = path.join(ROOT, "image-originals");
|
||||||
const LQIP_OUT = path.join(ROOT, "src/app/tours/chua-thai-lac.lqip.ts");
|
const LQIP_OUT = path.join(ROOT, "src/app/tours/chua-thai-lac.lqip.ts");
|
||||||
|
|
||||||
const PANO_WIDTH = 6144;
|
const PANO_WIDTH = 4096;
|
||||||
|
const PANO_WEBP_QUALITY = 72;
|
||||||
const PANO_QUALITY = 80;
|
const PANO_QUALITY = 80;
|
||||||
const LQIP_WIDTH = 32;
|
const LQIP_WIDTH = 32;
|
||||||
const MODAL_FILES = ["modal-1.png", "modal-2.png"];
|
const MODAL_FILES = ["modal-1.png", "modal-2.png"];
|
||||||
@@ -38,17 +39,31 @@ async function optimizePanoramas() {
|
|||||||
const src = await sourceFrom(dest, "chua-thai-lac");
|
const src = await sourceFrom(dest, "chua-thai-lac");
|
||||||
const before = (await fs.stat(dest)).size;
|
const before = (await fs.stat(dest)).size;
|
||||||
|
|
||||||
const buf = await sharp(src)
|
// JPEG (giữ cho next/image ở trang landing) — cũng hạ về 4096
|
||||||
|
const jpgBuf = await sharp(src)
|
||||||
.rotate()
|
.rotate()
|
||||||
.resize({ width: PANO_WIDTH, withoutEnlargement: true })
|
.resize({ width: PANO_WIDTH, withoutEnlargement: true })
|
||||||
.jpeg({ quality: PANO_QUALITY, mozjpeg: true, progressive: true })
|
.jpeg({ quality: PANO_QUALITY, mozjpeg: true, progressive: true })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
await fs.writeFile(dest, buf);
|
await fs.writeFile(dest, jpgBuf);
|
||||||
|
|
||||||
|
// WebP (tour viewer nạp bản này) — cùng tên, đuôi .webp
|
||||||
|
const webpName = f.replace(/\.jpe?g$/i, ".webp");
|
||||||
|
const webpDest = path.join(PANO_DIR, webpName);
|
||||||
|
const webpBuf = await sharp(src)
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: PANO_WIDTH, withoutEnlargement: true })
|
||||||
|
.webp({ quality: PANO_WEBP_QUALITY, effort: 6 })
|
||||||
|
.toBuffer();
|
||||||
|
await fs.writeFile(webpDest, webpBuf);
|
||||||
|
|
||||||
|
// LQIP khóa theo path .webp (đúng path tour yêu cầu)
|
||||||
const lqipBuf = await sharp(src).rotate().resize({ width: LQIP_WIDTH }).jpeg({ quality: 40 }).toBuffer();
|
const lqipBuf = await sharp(src).rotate().resize({ width: LQIP_WIDTH }).jpeg({ quality: 40 }).toBuffer();
|
||||||
lqip["/images/chua-thai-lac/" + f] = "data:image/jpeg;base64," + lqipBuf.toString("base64");
|
lqip["/images/chua-thai-lac/" + webpName] = "data:image/jpeg;base64," + lqipBuf.toString("base64");
|
||||||
|
|
||||||
console.log(`${f}: ${fmt(before)} -> ${fmt(buf.length)} (-${Math.round((1 - buf.length / before) * 100)}%)`);
|
console.log(
|
||||||
|
`${f}: ${fmt(before)} -> jpg ${fmt(jpgBuf.length)} | webp ${fmt(webpBuf.length)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return lqip;
|
return lqip;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// scripts/optimize-models.mjs
|
||||||
|
// Nén .glb: backup bản gốc rồi tối ưu (meshopt + nén texture webp).
|
||||||
|
// Chạy: node scripts/optimize-models.mjs (an toàn chạy lại — luôn nén từ backup gốc)
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const MODEL_DIR = path.join(ROOT, "public/antiquity");
|
||||||
|
const BACKUP_DIR = path.join(ROOT, "model-originals");
|
||||||
|
const fmt = (b) => (b / 1024 / 1024).toFixed(2) + "MB";
|
||||||
|
const exists = (p) => fs.access(p).then(() => true).catch(() => false);
|
||||||
|
|
||||||
|
async function optimizeModel(file) {
|
||||||
|
const dest = path.join(MODEL_DIR, file);
|
||||||
|
const backup = path.join(BACKUP_DIR, file);
|
||||||
|
await fs.mkdir(BACKUP_DIR, { recursive: true });
|
||||||
|
if (!(await exists(backup))) {
|
||||||
|
await fs.copyFile(dest, backup);
|
||||||
|
}
|
||||||
|
const before = (await fs.stat(dest)).size;
|
||||||
|
// Luôn tối ưu TỪ backup gốc; ghi đè ra dest.
|
||||||
|
const isWin = process.platform === "win32";
|
||||||
|
const bin = path.join(ROOT, "node_modules/.bin", isWin ? "gltf-transform.cmd" : "gltf-transform");
|
||||||
|
const q = (s) => (isWin ? `"${s}"` : s);
|
||||||
|
await run(
|
||||||
|
q(bin),
|
||||||
|
[
|
||||||
|
"optimize",
|
||||||
|
q(backup),
|
||||||
|
q(dest),
|
||||||
|
"--compress", "meshopt",
|
||||||
|
"--texture-compress", "webp",
|
||||||
|
],
|
||||||
|
// Trên Windows phải chạy .cmd qua shell (Node >=18 ném EINVAL nếu spawn .cmd trực tiếp).
|
||||||
|
{ shell: isWin }
|
||||||
|
);
|
||||||
|
const after = (await fs.stat(dest)).size;
|
||||||
|
console.log(`${file}: ${fmt(before)} -> ${fmt(after)} (-${Math.round((1 - after / before) * 100)}%)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = (await fs.readdir(MODEL_DIR)).filter((f) => /\.glb$/i.test(f)).sort();
|
||||||
|
for (const f of files) {
|
||||||
|
await optimizeModel(f);
|
||||||
|
}
|
||||||
|
console.log("Done.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("optimize-models failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import type { Antiquity } from "@/app/content";
|
||||||
|
|
||||||
|
const AntiquityShowcase = dynamic(
|
||||||
|
() => import("@/app/components/antiquity/AntiquityShowcase").then((m) => m.AntiquityShowcase),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-16 text-center text-sm text-[var(--muted-foreground)] sm:px-6">
|
||||||
|
Đang tải phòng trưng bày 3D…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function AntiquityShowcaseClient({ items }: { items: Antiquity[] }) {
|
||||||
|
return <AntiquityShowcase items={items} />;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Boxes, ScanLine, Sparkles } from "lucide-react";
|
import { Boxes, ScanLine, Sparkles } from "lucide-react";
|
||||||
import { AntiquityShowcase } from "@/app/components/antiquity/AntiquityShowcase";
|
import AntiquityShowcaseClient from "./AntiquityShowcaseClient";
|
||||||
import { BackToTopButton } from "@/app/components/landing/BackToTopButton";
|
import { BackToTopButton } from "@/app/components/landing/BackToTopButton";
|
||||||
import { SiteFooter } from "@/app/components/landing/SiteFooter";
|
import { SiteFooter } from "@/app/components/landing/SiteFooter";
|
||||||
import { SiteHeader } from "@/app/components/landing/SiteHeader";
|
import { SiteHeader } from "@/app/components/landing/SiteHeader";
|
||||||
@@ -40,7 +40,7 @@ export default function AntiquityPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AntiquityShowcase items={antiquities} />
|
<AntiquityShowcaseClient items={antiquities} />
|
||||||
|
|
||||||
<section className="border-t border-[var(--surface-border)] bg-[var(--surface-band)]">
|
<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">
|
<div className="mx-auto grid max-w-7xl gap-4 px-4 py-14 sm:px-6 md:grid-cols-3 lg:py-18">
|
||||||
|
|||||||
@@ -216,6 +216,38 @@ const panoramaTexturePromises = new Map<string, Promise<THREE.Texture>>();
|
|||||||
const panoramaTextureProgress = new Map<string, number>();
|
const panoramaTextureProgress = new Map<string, number>();
|
||||||
const panoramaTextureProgressListeners = new Map<string, Set<(progress: number) => void>>();
|
const panoramaTextureProgressListeners = new Map<string, Set<(progress: number) => void>>();
|
||||||
|
|
||||||
|
const MAX_CACHED_PANORAMAS = 8; // đủ cho cảnh hiện tại + các cảnh lân cận
|
||||||
|
const pinnedPanoramaUrls = new Set<string>();
|
||||||
|
|
||||||
|
// Ghim các ảnh không được phép dispose (cảnh hiện tại + lân cận).
|
||||||
|
function setPinnedPanoramas(urls: string[]) {
|
||||||
|
pinnedPanoramaUrls.clear();
|
||||||
|
urls.forEach((url) => pinnedPanoramaUrls.add(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Đánh dấu ảnh là mới-dùng-nhất (Map giữ thứ tự chèn) rồi dọn nếu vượt giới hạn.
|
||||||
|
function touchPanoramaCache(image: string, texture: THREE.Texture) {
|
||||||
|
panoramaTextureCache.delete(image);
|
||||||
|
panoramaTextureCache.set(image, texture);
|
||||||
|
evictPanoramaTextures();
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictPanoramaTextures() {
|
||||||
|
if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [url, texture] of panoramaTextureCache) {
|
||||||
|
if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (pinnedPanoramaUrls.has(url)) {
|
||||||
|
continue; // không dispose ảnh đang ghim (đang hiển thị/lân cận)
|
||||||
|
}
|
||||||
|
panoramaTextureCache.delete(url);
|
||||||
|
texture.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function notifyPanoramaProgress(image: string, progress: number) {
|
function notifyPanoramaProgress(image: string, progress: number) {
|
||||||
panoramaTextureProgress.set(image, progress);
|
panoramaTextureProgress.set(image, progress);
|
||||||
panoramaTextureProgressListeners.get(image)?.forEach((listener) => listener(progress));
|
panoramaTextureProgressListeners.get(image)?.forEach((listener) => listener(progress));
|
||||||
@@ -243,6 +275,7 @@ function loadPanoramaTexture(image: string, onProgress?: (progress: number) => v
|
|||||||
const cachedTexture = panoramaTextureCache.get(image);
|
const cachedTexture = panoramaTextureCache.get(image);
|
||||||
|
|
||||||
if (cachedTexture) {
|
if (cachedTexture) {
|
||||||
|
touchPanoramaCache(image, cachedTexture);
|
||||||
onProgress?.(100);
|
onProgress?.(100);
|
||||||
return Promise.resolve(cachedTexture);
|
return Promise.resolve(cachedTexture);
|
||||||
}
|
}
|
||||||
@@ -263,6 +296,7 @@ function loadPanoramaTexture(image: string, onProgress?: (progress: number) => v
|
|||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
texture.anisotropy = 8;
|
texture.anisotropy = 8;
|
||||||
panoramaTextureCache.set(image, texture);
|
panoramaTextureCache.set(image, texture);
|
||||||
|
evictPanoramaTextures();
|
||||||
panoramaTexturePromises.delete(image);
|
panoramaTexturePromises.delete(image);
|
||||||
notifyPanoramaProgress(image, 100);
|
notifyPanoramaProgress(image, 100);
|
||||||
resolve(texture);
|
resolve(texture);
|
||||||
@@ -1311,6 +1345,12 @@ function TourExperience({
|
|||||||
|
|
||||||
const preloadTourScene = useCallback(
|
const preloadTourScene = useCallback(
|
||||||
(scene: TourScene) => {
|
(scene: TourScene) => {
|
||||||
|
// Ghim ảnh đang dùng + các cảnh lân cận để LRU không dispose nhầm.
|
||||||
|
const neighborImages = scene.hotspots
|
||||||
|
.map((hotspot) => tourConfig.sceneById.get(hotspot.targetId)?.image)
|
||||||
|
.filter((image): image is string => Boolean(image));
|
||||||
|
setPinnedPanoramas([scene.image, ...neighborImages]);
|
||||||
|
|
||||||
const preloadImage = (image: string) => {
|
const preloadImage = (image: string) => {
|
||||||
loadPanoramaTexture(image)
|
loadPanoramaTexture(image)
|
||||||
.then((texture) => warmPanoramaTexture(image, texture))
|
.then((texture) => warmPanoramaTexture(image, texture))
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import * as THREE from "three";
|
|||||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||||
|
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
||||||
|
import { createRenderGate } from "@/app/lib/render-gate";
|
||||||
import { Rotate3d, Pause, Play, Maximize2 } from "lucide-react";
|
import { Rotate3d, Pause, Play, Maximize2 } from "lucide-react";
|
||||||
|
|
||||||
type Status = "loading" | "ready" | "error";
|
type Status = "loading" | "ready" | "error";
|
||||||
@@ -35,7 +37,7 @@ export function AntiquityViewer({ src }: { src: string }) {
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
renderer.toneMappingExposure = 1.08;
|
renderer.toneMappingExposure = 1.08;
|
||||||
renderer.domElement.style.cssText = "width:100%;height:100%;display:block;cursor:grab;";
|
renderer.domElement.style.cssText = "width:100%;height:100%;display:block;cursor:grab;";
|
||||||
@@ -70,6 +72,18 @@ export function AntiquityViewer({ src }: { src: string }) {
|
|||||||
});
|
});
|
||||||
controlsRef.current = controls;
|
controlsRef.current = controls;
|
||||||
|
|
||||||
|
let needsRender = true;
|
||||||
|
let active = true;
|
||||||
|
const requestRender = () => {
|
||||||
|
needsRender = true;
|
||||||
|
};
|
||||||
|
controls.addEventListener("change", requestRender);
|
||||||
|
|
||||||
|
const stopGate = createRenderGate(mount, (isActive) => {
|
||||||
|
active = isActive;
|
||||||
|
if (isActive) requestRender();
|
||||||
|
});
|
||||||
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
const w = mount.clientWidth;
|
const w = mount.clientWidth;
|
||||||
const h = mount.clientHeight;
|
const h = mount.clientHeight;
|
||||||
@@ -77,12 +91,14 @@ export function AntiquityViewer({ src }: { src: string }) {
|
|||||||
renderer.setSize(w, h, false);
|
renderer.setSize(w, h, false);
|
||||||
camera.aspect = w / h;
|
camera.aspect = w / h;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
|
needsRender = true;
|
||||||
};
|
};
|
||||||
const observer = new ResizeObserver(resize);
|
const observer = new ResizeObserver(resize);
|
||||||
observer.observe(mount);
|
observer.observe(mount);
|
||||||
resize();
|
resize();
|
||||||
|
|
||||||
const loader = new GLTFLoader();
|
const loader = new GLTFLoader();
|
||||||
|
loader.setMeshoptDecoder(MeshoptDecoder);
|
||||||
loader.load(
|
loader.load(
|
||||||
src,
|
src,
|
||||||
(gltf) => {
|
(gltf) => {
|
||||||
@@ -108,6 +124,7 @@ export function AntiquityViewer({ src }: { src: string }) {
|
|||||||
|
|
||||||
homeRef.current = { pos: camera.position.clone(), target: controls.target.clone() };
|
homeRef.current = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
|
requestRender();
|
||||||
},
|
},
|
||||||
(event) => {
|
(event) => {
|
||||||
if (event.total) setProgress(Math.round((event.loaded / event.total) * 100));
|
if (event.total) setProgress(Math.round((event.loaded / event.total) * 100));
|
||||||
@@ -118,9 +135,14 @@ export function AntiquityViewer({ src }: { src: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
controls.update();
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
raf = requestAnimationFrame(tick);
|
raf = requestAnimationFrame(tick);
|
||||||
|
if (!active) return;
|
||||||
|
// controls.update() trả về true khi camera còn chuyển động (damping/autoRotate)
|
||||||
|
const moving = controls.update();
|
||||||
|
if (needsRender || moving || controls.autoRotate) {
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
needsRender = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
|
|
||||||
@@ -128,6 +150,8 @@ export function AntiquityViewer({ src }: { src: string }) {
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
|
stopGate();
|
||||||
|
controls.removeEventListener("change", requestRender);
|
||||||
controls.dispose();
|
controls.dispose();
|
||||||
scene.traverse((object) => {
|
scene.traverse((object) => {
|
||||||
const mesh = object as THREE.Mesh;
|
const mesh = object as THREE.Mesh;
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import * as THREE from "three";
|
|
||||||
|
|
||||||
interface HeroPanoramaBackgroundProps {
|
|
||||||
imageSrc: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeroPanoramaBackground({ imageSrc }: HeroPanoramaBackgroundProps) {
|
|
||||||
const mountRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mount = mountRef.current;
|
|
||||||
|
|
||||||
if (!mount) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({
|
|
||||||
alpha: true,
|
|
||||||
antialias: true,
|
|
||||||
powerPreference: "high-performance",
|
|
||||||
});
|
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.6));
|
|
||||||
renderer.setSize(mount.clientWidth, mount.clientHeight);
|
|
||||||
renderer.domElement.className = "h-full w-full";
|
|
||||||
mount.appendChild(renderer.domElement);
|
|
||||||
|
|
||||||
const camera = new THREE.PerspectiveCamera(
|
|
||||||
58,
|
|
||||||
mount.clientWidth / Math.max(mount.clientHeight, 1),
|
|
||||||
0.1,
|
|
||||||
1000,
|
|
||||||
);
|
|
||||||
camera.position.set(0, 0, 0.1);
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
const geometry = new THREE.SphereGeometry(500, 96, 64);
|
|
||||||
geometry.scale(-1, 1, 1);
|
|
||||||
|
|
||||||
const material = new THREE.MeshBasicMaterial({
|
|
||||||
color: 0xffffff,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0,
|
|
||||||
});
|
|
||||||
const sphere = new THREE.Mesh(geometry, material);
|
|
||||||
sphere.rotation.y = -0.28;
|
|
||||||
scene.add(sphere);
|
|
||||||
|
|
||||||
let frame = 0;
|
|
||||||
let disposed = false;
|
|
||||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
const loader = new THREE.TextureLoader();
|
|
||||||
|
|
||||||
loader.load(
|
|
||||||
imageSrc,
|
|
||||||
(texture) => {
|
|
||||||
if (disposed) {
|
|
||||||
texture.dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
|
||||||
texture.minFilter = THREE.LinearFilter;
|
|
||||||
material.map = texture;
|
|
||||||
material.opacity = 1;
|
|
||||||
material.needsUpdate = true;
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
() => {
|
|
||||||
material.opacity = 0;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
const width = mount.clientWidth;
|
|
||||||
const height = Math.max(mount.clientHeight, 1);
|
|
||||||
camera.aspect = width / height;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(width, height);
|
|
||||||
};
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
if (!reduceMotion) {
|
|
||||||
sphere.rotation.y += 0.00075;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
frame = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(resize);
|
|
||||||
resizeObserver.observe(mount);
|
|
||||||
animate();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
cancelAnimationFrame(frame);
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
geometry.dispose();
|
|
||||||
material.map?.dispose();
|
|
||||||
material.dispose();
|
|
||||||
renderer.dispose();
|
|
||||||
renderer.domElement.remove();
|
|
||||||
};
|
|
||||||
}, [imageSrc]);
|
|
||||||
|
|
||||||
return <div ref={mountRef} aria-hidden className="absolute inset-0" />;
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { MeshGradient } from "@paper-design/shaders-react";
|
import { MeshGradient } from "@paper-design/shaders-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { HeroPanoramaBackground } from "@/app/components/ui/hero-panorama-background";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createRenderGate } from "@/app/lib/render-gate";
|
||||||
|
|
||||||
interface HeroShaderBackgroundProps {
|
interface HeroShaderBackgroundProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -10,45 +11,37 @@ interface HeroShaderBackgroundProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HeroShaderBackground({ children, imageSrc }: HeroShaderBackgroundProps) {
|
export function HeroShaderBackground({ children, imageSrc }: HeroShaderBackgroundProps) {
|
||||||
return (
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
<div className="relative isolate overflow-hidden border-b border-[var(--surface-border)]">
|
const [shaderActive, setShaderActive] = useState(false);
|
||||||
<svg aria-hidden className="absolute inset-0 h-0 w-0">
|
|
||||||
<defs>
|
|
||||||
<filter id="heritage-glass-effect" x="-50%" y="-50%" width="200%" height="200%">
|
|
||||||
<feTurbulence baseFrequency="0.005" numOctaves="1" result="noise" />
|
|
||||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="0.3" />
|
|
||||||
<feColorMatrix
|
|
||||||
type="matrix"
|
|
||||||
values="1 0 0 0 0.03
|
|
||||||
0 1 0 0 0.04
|
|
||||||
0 0 1 0 0.02
|
|
||||||
0 0 0 0.88 0"
|
|
||||||
result="tint"
|
|
||||||
/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = rootRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
return createRenderGate(el, setShaderActive);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="relative isolate overflow-hidden border-b border-[var(--surface-border)]"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-0 bg-cover bg-center opacity-[0.58] saturate-[1.12] contrast-[1.04]"
|
className="absolute inset-0 bg-cover bg-center opacity-[0.58] saturate-[1.12] contrast-[1.04]"
|
||||||
style={{ backgroundImage: `url("${imageSrc}")` }}
|
style={{ backgroundImage: `url("${imageSrc}")` }}
|
||||||
/>
|
/>
|
||||||
<HeroPanoramaBackground imageSrc={imageSrc} />
|
|
||||||
<div aria-hidden className="absolute inset-0 bg-[rgb(243_247_240_/_0.12)]" />
|
<div aria-hidden className="absolute inset-0 bg-[rgb(243_247_240_/_0.12)]" />
|
||||||
|
|
||||||
<div aria-hidden className="absolute inset-0 overflow-hidden opacity-48 mix-blend-soft-light">
|
{/* Một lớp shader duy nhất, chỉ vẽ khi hero trong tầm nhìn + tab hiển thị */}
|
||||||
<MeshGradient
|
{shaderActive ? (
|
||||||
className="absolute inset-0 h-full w-full"
|
<div aria-hidden className="absolute inset-0 overflow-hidden opacity-40">
|
||||||
colors={["#f8fbf2", "#d6b468", "#e0eddf", "#245f4b", "#ffffff"]}
|
<MeshGradient
|
||||||
speed={0.28}
|
className="absolute inset-0 h-full w-full"
|
||||||
/>
|
colors={["#f8fbf2", "#d6b468", "#e0eddf", "#245f4b", "#ffffff"]}
|
||||||
<MeshGradient
|
speed={0.24}
|
||||||
className="absolute inset-0 h-full w-full opacity-45"
|
/>
|
||||||
colors={["#ffffff", "#e7cb81", "#dcebdd", "#0e3427"]}
|
</div>
|
||||||
speed={0.2}
|
) : null}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@@ -240,7 +240,6 @@ a {
|
|||||||
background-size: 180% 100%, 84px 84px, 84px 84px;
|
background-size: 180% 100%, 84px 84px, 84px 84px;
|
||||||
filter: none;
|
filter: none;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
animation: public-ambient-pan 18s ease-in-out infinite alternate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.public-page::after {
|
.public-page::after {
|
||||||
@@ -722,9 +721,8 @@ a {
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
var(--surface-shadow),
|
var(--surface-shadow),
|
||||||
inset 0 1px 0 rgb(255 255 255 / 0.88);
|
inset 0 1px 0 rgb(255 255 255 / 0.88);
|
||||||
backdrop-filter: blur(18px) saturate(132%);
|
backdrop-filter: blur(10px) saturate(120%);
|
||||||
-webkit-backdrop-filter: blur(18px) saturate(132%);
|
-webkit-backdrop-filter: blur(10px) saturate(120%);
|
||||||
will-change: transform;
|
|
||||||
transition:
|
transition:
|
||||||
transform 280ms cubic-bezier(0.16, 1, 0.3, 1),
|
transform 280ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
box-shadow 280ms cubic-bezier(0.16, 1, 0.3, 1),
|
box-shadow 280ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
@@ -778,9 +776,8 @@ a {
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
0 24px 66px rgb(37 75 54 / 0.13),
|
0 24px 66px rgb(37 75 54 / 0.13),
|
||||||
inset 0 1px 0 rgb(255 255 255 / 0.9);
|
inset 0 1px 0 rgb(255 255 255 / 0.9);
|
||||||
backdrop-filter: blur(12px) saturate(125%);
|
backdrop-filter: blur(8px) saturate(118%);
|
||||||
-webkit-backdrop-filter: blur(12px) saturate(125%);
|
-webkit-backdrop-filter: blur(8px) saturate(118%);
|
||||||
will-change: transform;
|
|
||||||
transition:
|
transition:
|
||||||
transform 280ms cubic-bezier(0.16, 1, 0.3, 1),
|
transform 280ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
box-shadow 280ms cubic-bezier(0.16, 1, 0.3, 1),
|
box-shadow 280ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
@@ -988,7 +985,6 @@ a {
|
|||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
animation: public-gradient-flow 8s ease-in-out infinite alternate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.public-shimmer::after {
|
.public-shimmer::after {
|
||||||
@@ -1161,19 +1157,6 @@ a {
|
|||||||
animation: public-header-drop 620ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
animation: public-header-drop 620ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.public-header::after {
|
|
||||||
animation: public-header-glint 7s ease-in-out 1.2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.public-logo-mark {
|
|
||||||
animation: public-logo-breathe 4.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.public-header-cta,
|
|
||||||
.public-cta {
|
|
||||||
animation: public-cta-breathe 4.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.public-hero-inner::before {
|
.public-hero-inner::before {
|
||||||
animation: public-line-draw 980ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both;
|
animation: public-line-draw 980ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both;
|
||||||
}
|
}
|
||||||
@@ -1182,14 +1165,6 @@ a {
|
|||||||
animation: public-line-draw 1100ms cubic-bezier(0.16, 1, 0.3, 1) 260ms both;
|
animation: public-line-draw 1100ms cubic-bezier(0.16, 1, 0.3, 1) 260ms both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.public-subpage-hero-image {
|
|
||||||
animation: public-photo-breathe 12s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.public-hero-inner .public-image-stage {
|
|
||||||
animation: public-stage-float 7.2s ease-in-out 900ms infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.public-hero-rule::before {
|
.public-hero-rule::before {
|
||||||
animation: public-line-draw 980ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both;
|
animation: public-line-draw 980ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both;
|
||||||
}
|
}
|
||||||
@@ -1228,10 +1203,6 @@ a {
|
|||||||
.public-card:hover::after {
|
.public-card:hover::after {
|
||||||
animation: public-sheen 820ms cubic-bezier(0.16, 1, 0.3, 1);
|
animation: public-sheen 820ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.public-back-to-top[data-visible="true"] {
|
|
||||||
animation: public-back-to-top-float 4.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -1267,45 +1238,8 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes public-header-glint {
|
|
||||||
0%,
|
|
||||||
58%,
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(0) skewX(-18deg);
|
|
||||||
}
|
|
||||||
68% {
|
|
||||||
opacity: 0.62;
|
|
||||||
}
|
|
||||||
82% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(760%) skewX(-18deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes public-logo-breathe {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgb(255 255 255 / 0.92),
|
|
||||||
0 10px 24px rgb(37 75 54 / 0.08);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgb(255 255 255 / 0.98),
|
|
||||||
0 16px 34px rgb(142 95 11 / 0.14);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes public-cta-breathe {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
filter: saturate(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
filter: saturate(1.08) brightness(1.03);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes public-rise {
|
@keyframes public-rise {
|
||||||
from {
|
from {
|
||||||
@@ -1329,23 +1263,7 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes public-ambient-pan {
|
|
||||||
from {
|
|
||||||
background-position: 0% 0%, 0 0, 0 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
background-position: 100% 0%, 84px 0, 0 84px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes public-gradient-flow {
|
|
||||||
from {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
background-position: 100% 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes public-line-draw {
|
@keyframes public-line-draw {
|
||||||
from {
|
from {
|
||||||
@@ -1372,33 +1290,8 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes public-photo-breathe {
|
|
||||||
from {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: scale(1.035);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes public-stage-float {
|
|
||||||
from {
|
|
||||||
transform: translate3d(0, 0, 0) rotateX(0deg) rotateY(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translate3d(0, -10px, 0) rotateX(0.4deg) rotateY(-0.55deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes public-back-to-top-float {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
filter: saturate(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
filter: saturate(1.08) brightness(1.03);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lớp nhiễu hạt phim cố định — tạo chiều sâu, chất liệu cao cấp cho nền tối. */
|
/* Lớp nhiễu hạt phim cố định — tạo chiều sâu, chất liệu cao cấp cho nền tối. */
|
||||||
.site-grain {
|
.site-grain {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// src/app/lib/render-gate.ts
|
||||||
|
// Chỉ cho phép vòng lặp vẽ (rAF/setAnimationLoop) chạy khi phần tử nằm trong vùng nhìn
|
||||||
|
// VÀ tab đang hiển thị. Gọi onChange(active) mỗi khi trạng thái đổi. Trả về hàm dọn dẹp.
|
||||||
|
export function createRenderGate(
|
||||||
|
element: Element,
|
||||||
|
onChange: (active: boolean) => void,
|
||||||
|
): () => void {
|
||||||
|
let onScreen = false;
|
||||||
|
let visible = typeof document === "undefined" ? true : !document.hidden;
|
||||||
|
let last = false;
|
||||||
|
|
||||||
|
const emit = () => {
|
||||||
|
const next = onScreen && visible;
|
||||||
|
if (next !== last) {
|
||||||
|
last = next;
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
onScreen = entries.some((entry) => entry.isIntersecting);
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
{ threshold: 0.01 },
|
||||||
|
);
|
||||||
|
io.observe(element);
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
visible = !document.hidden;
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
io.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const VirtualTour = dynamic(() => import("@/app/components/VirtualTour"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="grid min-h-[100dvh] place-items-center bg-[#f5f1e6] text-[#4a3f35]">
|
||||||
|
<p className="text-sm font-bold">Đang tải không gian 360°…</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function TourClient({ tourId }: { tourId?: string }) {
|
||||||
|
return <VirtualTour tourId={tourId} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import VirtualTour from "@/app/components/VirtualTour";
|
import TourClient from "./TourClient";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "VR360 Chùa Thái Lạc (Pháp Vân tự)",
|
title: "VR360 Chùa Thái Lạc (Pháp Vân tự)",
|
||||||
@@ -8,5 +8,5 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ChuaThaiLacTourPage() {
|
export default function ChuaThaiLacTourPage() {
|
||||||
return <VirtualTour tourId="chua-thai-lac" />;
|
return <TourClient tourId="chua-thai-lac" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
// TỰ ĐỘNG SINH bởi scripts/optimize-images.mjs — KHÔNG sửa tay.
|
// TỰ ĐỘNG SINH bởi scripts/optimize-images.mjs — KHÔNG sửa tay.
|
||||||
// Ảnh mờ tạm (blur-up/LQIP) base64 cho mỗi panorama, khoá theo đường dẫn ảnh.
|
// Ảnh mờ tạm (blur-up/LQIP) base64 cho mỗi panorama, khoá theo đường dẫn ảnh.
|
||||||
export const lqip: Record<string, string> = {
|
export const lqip: Record<string, string> = {
|
||||||
"/images/chua-thai-lac/01.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAABQECBP/EACgQAAIBAwEFCQAAAAAAAAAAAAECAwAEETEFEhMhUSIjMjNBYXGBof/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABYRAQEBAAAAAAAAAAAAAAAAAAABEf/aAAwDAQACEQMRAD8As77OlaO3Ktuq3mnByPjpz1oW7sRC5KhSh5Aqxx9eppwzk+HBIOoIq8SKUFJHQsMEHUA1mrIDtrWMwtLIyDA3gC35SFlLwpxFIUaBVzJ3ecg9PeutrZJY+0iFeoIraPHFGI0IyNd3mBTTH//Z",
|
"/images/chua-thai-lac/01.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAABQECBP/EACgQAAIBAwEFCQAAAAAAAAAAAAECAwAEETEFEhMhUSIjMjNBYXGBof/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABYRAQEBAAAAAAAAAAAAAAAAAAABEf/aAAwDAQACEQMRAD8As77OlaO3Ktuq3mnByPjpz1oW7sRC5KhSh5Aqxx9eppwzk+HBIOoIq8SKUFJHQsMEHUA1mrIDtrWMwtLIyDA3gC35SFlLwpxFIUaBVzJ3ecg9PeutrZJY+0iFeoIraPHFGI0IyNd3mBTTH//Z",
|
||||||
"/images/chua-thai-lac/02.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAAMEBf/EACMQAAIBBAICAgMAAAAAAAAAAAECAwAEERIFMSFxEyJhcoH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABcRAQEBAQAAAAAAAAAAAAAAAAARARL/2gAMAwEAAhEDEQA/ACLmgZUjkttWIBOCfFXScvaxWssjRnKEAAr3no+qmexQvtoVcjGw7p0FikcJSNXkLeTkFvVTrSM+Ll5EkDXADxk4KxqNgexVZ5SJrB50t3WQHVY27b8/ymixRSSYTn9KbJZJND8ckZKn7DKkYNLpH//Z",
|
"/images/chua-thai-lac/02.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAAMEBf/EACMQAAIBBAICAgMAAAAAAAAAAAECAwAEERIFMSFxEyJhcoH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABcRAQEBAQAAAAAAAAAAAAAAAAARARL/2gAMAwEAAhEDEQA/ACLmgZUjkttWIBOCfFXScvaxWssjRnKEAAr3no+qmexQvtoVcjGw7p0FikcJSNXkLeTkFvVTrSM+Ll5EkDXADxk4KxqNgexVZ5SJrB50t3WQHVY27b8/ymixRSSYTn9KbJZJND8ckZKn7DKkYNLpH//Z",
|
||||||
"/images/chua-thai-lac/03.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEBf/EACQQAAIBAwMEAwEAAAAAAAAAAAECAwAREgQFITFBcYEUFSKR/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/ALNHvME72YCMY5XzB9eaZrNyHw45dOyAEkMWHI9VP9dGyhWCoAMQc+170WhgIEaqMF4AtUukQR7xqYGDTAzRvexva9u4rUbcYAiOLkOuQ5/g80mPSwLb8Jx046U2TRQakiRSM4+AA1gaXcSP/9k=",
|
"/images/chua-thai-lac/03.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEBf/EACQQAAIBAwMEAwEAAAAAAAAAAAECAwAREgQFITFBcYEUFSKR/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/ALNHvME72YCMY5XzB9eaZrNyHw45dOyAEkMWHI9VP9dGyhWCoAMQc+170WhgIEaqMF4AtUukQR7xqYGDTAzRvexva9u4rUbcYAiOLkOuQ5/g80mPSwLb8Jx046U2TRQakiRSM4+AA1gaXcSP/9k=",
|
||||||
"/images/chua-thai-lac/04.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAwQC/8QAIxAAAgIBBAICAwAAAAAAAAAAAQIDEQQABRIhEzEiQWGh4f/EABQBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEgH/2gAMAwEAAhEDEQA/ALMfdMOReXl8YuvkPf8ANOd0x5MRpIeLEGqNAnRT4uPNGAWUGqHXr8ami2yGI9OoX3Siu9FmWId2idh54nRT3YN6pizsd+QYrG6mirH96zJhYjKF4lSKAYN9aOLEg5uVd+JNkX0dF6Jf/9k=",
|
"/images/chua-thai-lac/04.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAwQC/8QAIxAAAgIBBAICAwAAAAAAAAAAAQIDEQQABRIhEzEiQWGh4f/EABQBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEgH/2gAMAwEAAhEDEQA/ALMfdMOReXl8YuvkPf8ANOd0x5MRpIeLEGqNAnRT4uPNGAWUGqHXr8ami2yGI9OoX3Siu9FmWId2idh54nRT3YN6pizsd+QYrG6mirH96zJhYjKF4lSKAYN9aOLEg5uVd+JNkX0dF6Jf/9k=",
|
||||||
"/images/chua-thai-lac/05.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGQAAAQUAAAAAAAAAAAAAAAAABAECAwUG/8QAIxAAAgEDAwQDAAAAAAAAAAAAAQIDAAQREiExBUFRgRMUkf/EABYBAQEBAAAAAAAAAAAAAAAAAAEAAv/EABcRAQEBAQAAAAAAAAAAAAAAAAAREgH/2gAMAwEAAhEDEQA/ACrS/wCmC2iaQIuvtIMnx6qCf6l9FK9i6oyYJDbD1ReI5LcqHXjY4GKEtLK1hlDNcK5Bzpz3rOllQtJKFUtE++4OCM+aSMvqJIcHkbYGK0FykEkufmiYDhS3H5TVit8DOhGHOptyKL0x/9k=",
|
"/images/chua-thai-lac/05.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGQAAAQUAAAAAAAAAAAAAAAAABAECAwUG/8QAIxAAAgEDAwQDAAAAAAAAAAAAAQIDAAQREiExBUFRgRMUkf/EABYBAQEBAAAAAAAAAAAAAAAAAAEAAv/EABcRAQEBAQAAAAAAAAAAAAAAAAAREgH/2gAMAwEAAhEDEQA/ACrS/wCmC2iaQIuvtIMnx6qCf6l9FK9i6oyYJDbD1ReI5LcqHXjY4GKEtLK1hlDNcK5Bzpz3rOllQtJKFUtE++4OCM+aSMvqJIcHkbYGK0FykEkufmiYDhS3H5TVit8DOhGHOptyKL0x/9k=",
|
||||||
"/images/chua-thai-lac/06.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGQAAAgMBAAAAAAAAAAAAAAAAAgQBAwUG/8QAJRAAAgICAQMDBQAAAAAAAAAAAQIDBAARIRIxYQUyQRVRUnHB/8QAFQEBAQAAAAAAAAAAAAAAAAAAAgH/xAAaEQACAwEBAAAAAAAAAAAAAAAAAQIRIRJR/9oADAMBAAIRAxEAPwALiU7EDmvGI50UFtNpd/bWZcdGad0H5nR6T7R3JzoJqSKVavMIjrTcA78/vFVgsSWiXnCgjlwvOC34QGKD06tMERprBX50Cr+PGMPIjzKfp6JpSSAQRv44/mRYqtG3VWeP29Oie+VR17UzASOiKO+jhcp3iEua0//Z",
|
"/images/chua-thai-lac/06.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGQAAAgMBAAAAAAAAAAAAAAAAAgQBAwUG/8QAJRAAAgICAQMDBQAAAAAAAAAAAQIDBAARIRIxYQUyQRVRUnHB/8QAFQEBAQAAAAAAAAAAAAAAAAAAAgH/xAAaEQACAwEBAAAAAAAAAAAAAAAAAQIRIRJR/9oADAMBAAIRAxEAPwALiU7EDmvGI50UFtNpd/bWZcdGad0H5nR6T7R3JzoJqSKVavMIjrTcA78/vFVgsSWiXnCgjlwvOC34QGKD06tMERprBX50Cr+PGMPIjzKfp6JpSSAQRv44/mRYqtG3VWeP29Oie+VR17UzASOiKO+jhcp3iEua0//Z",
|
||||||
"/images/chua-thai-lac/07.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAAQCAwX/xAAiEAACAQMFAAMBAAAAAAAAAAABAgMABBEFEiEiMRVBgZH/xAAVAQEBAAAAAAAAAAAAAAAAAAABAP/EABkRAAMAAwAAAAAAAAAAAAAAAAABAgMREv/aAAwDAQACEQMRAD8A0k1C0jCl5lUEZ8zUItatrqSROsYTxiR2pJLCBWyIGBGOWer5rOGSMKm0bh2AFDsVOw+RtGcgyMozw2ODRPf20Eayb94bwLyf5S1tpiQSbgDkjHY+U3Nbq8AjTn6fadrfhoWQuD//2Q==",
|
"/images/chua-thai-lac/07.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAAQCAwX/xAAiEAACAQMFAAMBAAAAAAAAAAABAgMABBEFEiEiMRVBgZH/xAAVAQEBAAAAAAAAAAAAAAAAAAABAP/EABkRAAMAAwAAAAAAAAAAAAAAAAABAgMREv/aAAwDAQACEQMRAD8A0k1C0jCl5lUEZ8zUItatrqSROsYTxiR2pJLCBWyIGBGOWer5rOGSMKm0bh2AFDsVOw+RtGcgyMozw2ODRPf20Eayb94bwLyf5S1tpiQSbgDkjHY+U3Nbq8AjTn6fadrfhoWQuD//2Q==",
|
||||||
"/images/chua-thai-lac/08.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEAP/EACkQAAIBAgIIBwAAAAAAAAAAAAECEQADBAUSExQVIUFRgSJCUmFxkdH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABcRAAMBAAAAAAAAAAAAAAAAAAABERL/2gAMAwEAAhEDEQA/AK97ZeonXp9U21nGAvhlR1keZkianuHDtytmOgFEPbA8JUdxU0xEK3lhbl0jiqesn55dqaMwwCaJN8AMJEg/lbWWQRxSesChtGHdtFnU+zRV0xEf/9k=",
|
"/images/chua-thai-lac/08.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEAP/EACkQAAIBAgIIBwAAAAAAAAAAAAECEQADBAUSExQVIUFRgSJCUmFxkdH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABcRAAMBAAAAAAAAAAAAAAAAAAABERL/2gAMAwEAAhEDEQA/AK97ZeonXp9U21nGAvhlR1keZkianuHDtytmOgFEPbA8JUdxU0xEK3lhbl0jiqesn55dqaMwwCaJN8AMJEg/lbWWQRxSesChtGHdtFnU+zRV0xEf/9k=",
|
||||||
"/images/chua-thai-lac/09.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAAIEA//EACMQAAIBAwQBBQAAAAAAAAAAAAECAwAEEQUSITFBIiNRcpH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABYRAQEBAAAAAAAAAAAAAAAAAAASAf/aAAwDAQACEQMRAD8Ast9Xs5I42kdYnYZIY9GmutZs40Qh1cHsoOqMQjG1EHHgDihhE+NwDY+cGpWkornW7dFjaBGmLH1LnbtFa32qw2sCSQoZWboHIAGPNU+0vACfgpSIZOGSMj6ilaTj/9k=",
|
"/images/chua-thai-lac/09.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAAIEA//EACMQAAIBAwQBBQAAAAAAAAAAAAECAwAEEQUSITFBIiNRcpH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABYRAQEBAAAAAAAAAAAAAAAAAAASAf/aAAwDAQACEQMRAD8Ast9Xs5I42kdYnYZIY9GmutZs40Qh1cHsoOqMQjG1EHHgDihhE+NwDY+cGpWkornW7dFjaBGmLH1LnbtFa32qw2sCSQoZWboHIAGPNU+0vACfgpSIZOGSMj6ilaTj/9k=",
|
||||||
"/images/chua-thai-lac/10.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAABAUAAgP/xAAkEAACAQMCBwEBAAAAAAAAAAABAgMABBEFMRITFCEiYXFRgf/EABUBAQEAAAAAAAAAAAAAAAAAAAEC/8QAGBEBAAMBAAAAAAAAAAAAAAAAAAEDEhP/2gAMAwEAAhEDEQA/ADZdZigQPMrKpOAV74+1JNdtYngUlzzdyBng+0OUik8X4WX5WhitvHgjVSuxo2vnK+qazyYV6MxSSE4O/Yfvul1hrF686dQUaLOCcAH+UwuAksYBKE+xQyW0CHOIwRsSBRoxW//Z",
|
"/images/chua-thai-lac/10.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAABAUAAgP/xAAkEAACAQMCBwEBAAAAAAAAAAABAgMABBEFMRITFCEiYXFRgf/EABUBAQEAAAAAAAAAAAAAAAAAAAEC/8QAGBEBAAMBAAAAAAAAAAAAAAAAAAEDEhP/2gAMAwEAAhEDEQA/ADZdZigQPMrKpOAV74+1JNdtYngUlzzdyBng+0OUik8X4WX5WhitvHgjVSuxo2vnK+qazyYV6MxSSE4O/Yfvul1hrF686dQUaLOCcAH+UwuAksYBKE+xQyW0CHOIwRsSBRoxW//Z",
|
||||||
"/images/chua-thai-lac/11.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAgMEAQX/xAAlEAACAQMEAgEFAAAAAAAAAAABAgMABBEFEiExEyJRFEFSYYH/xAAUAQEAAAAAAAAAAAAAAAAAAAAB/8QAFxEBAQEBAAAAAAAAAAAAAAAAAQAREv/aAAwDAQACEQMRAD8AZqurMsEa2E+yV+WOB6j4PwaVpTXyXm++u5XhRD2cgmr7i0tpguSW29ZPVAtonj2FjtDZ4JBo1nCK31eK58uyOUCMZ98DP6FQxa4wdpJAWRuBH91/tdAWlqQN4LY/NycVv0ViHZ1jUMeyOKOljC//2Q==",
|
"/images/chua-thai-lac/11.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAgMEAQX/xAAlEAACAQMEAgEFAAAAAAAAAAABAgMABBEFEiExEyJRFEFSYYH/xAAUAQEAAAAAAAAAAAAAAAAAAAAB/8QAFxEBAQEBAAAAAAAAAAAAAAAAAQAREv/aAAwDAQACEQMRAD8AZqurMsEa2E+yV+WOB6j4PwaVpTXyXm++u5XhRD2cgmr7i0tpguSW29ZPVAtonj2FjtDZ4JBo1nCK31eK58uyOUCMZ98DP6FQxa4wdpJAWRuBH91/tdAWlqQN4LY/NycVv0ViHZ1jUMeyOKOljC//2Q==",
|
||||||
"/images/chua-thai-lac/12.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAABAP/xAAnEAACAQMCAwkAAAAAAAAAAAABAgMABBEFIRJBYRMVIiMyUXGRof/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABgRAAMBAQAAAAAAAAAAAAAAAAABAhEh/9oADAMBAAIRAxEAPwBR1LyTJGwaVV9IBwT1HtQu9dRJZnUADB8KjHxTruwSVENs4R1XG52YdaKum3DkJIvAnMqwNZbocLyamyxKjMgmfccAOB986LJrNwkhjQR5GNypxmn3FgknZlHEbKOHcZGKMbAs2HmjG+5C5/aOqJh//9k=",
|
"/images/chua-thai-lac/12.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAABAP/xAAnEAACAQMCAwkAAAAAAAAAAAABAgMABBEFIRJBYRMVIiMyUXGRof/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABgRAAMBAQAAAAAAAAAAAAAAAAABAhEh/9oADAMBAAIRAxEAPwBR1LyTJGwaVV9IBwT1HtQu9dRJZnUADB8KjHxTruwSVENs4R1XG52YdaKum3DkJIvAnMqwNZbocLyamyxKjMgmfccAOB986LJrNwkhjQR5GNypxmn3FgknZlHEbKOHcZGKMbAs2HmjG+5C5/aOqJh//9k=",
|
||||||
"/images/chua-thai-lac/13.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAABAIDBf/EACMQAAICAQIHAQEAAAAAAAAAAAECAxEABBIFEyEiMVFhQRT/xAAWAQEBAQAAAAAAAAAAAAAAAAABAAL/xAAZEQACAwEAAAAAAAAAAAAAAAAAEQECElH/2gAMAwEAAhEDEQA/ACPMDp5DzZGlDdjDr0+3izxCTU6YwgkBVo0m8k+/GJk4dC6s6m2K1TH995KHQvCq8rbuqmLfuZdhUMy0eKPUxW8oViQ5ksLXsfcvGsDyqg1M38yjt7e4+b6ivWN1XDRIqyGTc6eFrocJyWDBBHIisKZt10Pl4atwlB//2Q==",
|
"/images/chua-thai-lac/13.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAABAIDBf/EACMQAAICAQIHAQEAAAAAAAAAAAECAxEABBIFEyEiMVFhQRT/xAAWAQEBAQAAAAAAAAAAAAAAAAABAAL/xAAZEQACAwEAAAAAAAAAAAAAAAAAEQECElH/2gAMAwEAAhEDEQA/ACPMDp5DzZGlDdjDr0+3izxCTU6YwgkBVo0m8k+/GJk4dC6s6m2K1TH995KHQvCq8rbuqmLfuZdhUMy0eKPUxW8oViQ5ksLXsfcvGsDyqg1M38yjt7e4+b6ivWN1XDRIqyGTc6eFrocJyWDBBHIisKZt10Pl4atwlB//2Q==",
|
||||||
"/images/chua-thai-lac/14.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAABAMBBf/EACUQAAIBAwIFBQAAAAAAAAAAAAECAwAEERIhEyMxQVEUMmFxkf/EABYBAQEBAAAAAAAAAAAAAAAAAAIAA//EABkRAAMAAwAAAAAAAAAAAAAAAAABEQISIf/aAAwDAQACEQMRAD8APLbBghQM506mBPt+Kk8aqoYZHbIBI+669w1nJIhkSNVC6TliMDtRZIbJITH6hAhOVOsGs3gSSChRCnEWR8/gNagZ445Uzzd13Jz560mERSqyxyZC9CW26eKpaQyRnnSRsg3VUOMUdOQah//Z",
|
"/images/chua-thai-lac/14.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAABAMBBf/EACUQAAIBAwIFBQAAAAAAAAAAAAECAwAEERIhEyMxQVEUMmFxkf/EABYBAQEBAAAAAAAAAAAAAAAAAAIAA//EABkRAAMAAwAAAAAAAAAAAAAAAAABEQISIf/aAAwDAQACEQMRAD8APLbBghQM506mBPt+Kk8aqoYZHbIBI+669w1nJIhkSNVC6TliMDtRZIbJITH6hAhOVOsGs3gSSChRCnEWR8/gNagZ445Uzzd13Jz560mERSqyxyZC9CW26eKpaQyRnnSRsg3VUOMUdOQah//Z",
|
||||||
"/images/chua-thai-lac/15.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAABAIDBf/EACMQAAICAgICAQUAAAAAAAAAAAECAxEABAUhEyISFTEyM3H/xAAVAQEBAAAAAAAAAAAAAAAAAAACAf/EABgRAAMBAQAAAAAAAAAAAAAAAAABAhEh/9oADAMBAAIRAxEAPwBsfNxvqRFmBn6DqRXeR+rkyyEqBEnRA7N/3E7Wlo7SqG8SuoADWLFYccdpoWZpEotdAisO0XEA5Dcn3NgrpPJ4kA/Wasn74OCfcLP4JJy0Z9uz1m/LriR1l1thY6FfEGhlUfHWX8kigMfam/LC3WjlTnT/2Q==",
|
"/images/chua-thai-lac/15.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAABAIDBf/EACMQAAICAgICAQUAAAAAAAAAAAECAxEABAUhEyISFTEyM3H/xAAVAQEBAAAAAAAAAAAAAAAAAAACAf/EABgRAAMBAQAAAAAAAAAAAAAAAAABAhEh/9oADAMBAAIRAxEAPwBsfNxvqRFmBn6DqRXeR+rkyyEqBEnRA7N/3E7Wlo7SqG8SuoADWLFYccdpoWZpEotdAisO0XEA5Dcn3NgrpPJ4kA/Wasn74OCfcLP4JJy0Z9uz1m/LriR1l1thY6FfEGhlUfHWX8kigMfam/LC3WjlTnT/2Q==",
|
||||||
"/images/chua-thai-lac/16.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEAv/EACEQAAICAgMAAgMAAAAAAAAAAAECAxEAEgQhMUFREyJx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAED/8QAFxEBAQEBAAAAAAAAAAAAAAAAAQACEf/aAAwDAQACEQMRAD8AUvJvjKyMRfZ2HRxZeQwgvG6sR0D85Y8HGcBASF9Ftd/3A61CY3ZWQEVZFA5m5aoNiBDHCzu0RfWvxte1/YycpKpYvsGUbaeULyvjiKOczPIu2pXrw3jJV48nrsLFfq3x9YRYcL//2Q==",
|
"/images/chua-thai-lac/16.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEAv/EACEQAAICAgMAAgMAAAAAAAAAAAECAxEAEgQhMUFREyJx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAED/8QAFxEBAQEBAAAAAAAAAAAAAAAAAQACEf/aAAwDAQACEQMRAD8AUvJvjKyMRfZ2HRxZeQwgvG6sR0D85Y8HGcBASF9Ftd/3A61CY3ZWQEVZFA5m5aoNiBDHCzu0RfWvxte1/YycpKpYvsGUbaeULyvjiKOczPIu2pXrw3jJV48nrsLFfq3x9YRYcL//2Q==",
|
||||||
"/images/chua-thai-lac/17.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABQMG/8QAJBAAAgIBAwIHAAAAAAAAAAAAAQIDEQAEEiExQQUTFCJRgaH/xAAWAQEBAQAAAAAAAAAAAAAAAAADAAH/xAAaEQACAgMAAAAAAAAAAAAAAAAAAQMREyFB/9oADAMBAAIRAxEAPwBYy8Hb2NEnoMlLq3iVAIxIz8rR4/czom1BdTvZU6n3VzlfVvI4Ll5CtgBjYw7Y6iY2urlWPzZoNsYPJRgSPrKLroHra1k9B3wrUeJARiOMKhHcR2DhxMrQrU7ijzTE38ZW+GY3uz//2Q==",
|
"/images/chua-thai-lac/17.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABQMG/8QAJBAAAgIBAwIHAAAAAAAAAAAAAQIDEQAEEiExQQUTFCJRgaH/xAAWAQEBAQAAAAAAAAAAAAAAAAADAAH/xAAaEQACAgMAAAAAAAAAAAAAAAAAAQMREyFB/9oADAMBAAIRAxEAPwBYy8Hb2NEnoMlLq3iVAIxIz8rR4/czom1BdTvZU6n3VzlfVvI4Ll5CtgBjYw7Y6iY2urlWPzZoNsYPJRgSPrKLroHra1k9B3wrUeJARiOMKhHcR2DhxMrQrU7ijzTE38ZW+GY3uz//2Q==",
|
||||||
"/images/chua-thai-lac/18.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAED/8QAIRAAAgEEAgIDAAAAAAAAAAAAAQIDAAQRIRITImEUQdH/xAAWAQEBAQAAAAAAAAAAAAAAAAADAQL/xAAXEQEBAQEAAAAAAAAAAAAAAAAAEQEh/9oADAMBAAIRAxEAPwAHWwlCRCVig8goIJFLhmmiUE2UrKD5bDYrb45knuJo2ALgceRyCfyrbW9zJKnbxjjXZKnJPqjJ2iNIbu+IjgcoWIXOtD1Vmh6mKyRMFxrioNPmtl7i+wcnBFDWGPsZps5RsAknQ+qjU1//2Q==",
|
"/images/chua-thai-lac/18.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAED/8QAIRAAAgEEAgIDAAAAAAAAAAAAAQIDAAQRIRITImEUQdH/xAAWAQEBAQAAAAAAAAAAAAAAAAADAQL/xAAXEQEBAQEAAAAAAAAAAAAAAAAAEQEh/9oADAMBAAIRAxEAPwAHWwlCRCVig8goIJFLhmmiUE2UrKD5bDYrb45knuJo2ALgceRyCfyrbW9zJKnbxjjXZKnJPqjJ2iNIbu+IjgcoWIXOtD1Vmh6mKyRMFxrioNPmtl7i+wcnBFDWGPsZps5RsAknQ+qjU1//2Q==",
|
||||||
"/images/chua-thai-lac/19.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAgMEBf/EACUQAAIBAwQBBAMAAAAAAAAAAAECAwAEERIhMUEFExQiUSRhcf/EABYBAQEBAAAAAAAAAAAAAAAAAAIBA//EABcRAQEBAQAAAAAAAAAAAAAAAAEAAhH/2gAMAwEAAhEDEQA/AJ4vIe5DARMpHG3NaPj54HtzI7a3zsgxsPs0qO2iRhobSOxqzn978U6WC2lh0EIp41KBmga7a6EIze2pYqVxjsEEfzmsmTyP5cygFowcpgcjqr4LCCGQP6hkb6bBo7mCKRmLYBOPl3VdMcnb/9k=",
|
"/images/chua-thai-lac/19.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAgMEBf/EACUQAAIBAwQBBAMAAAAAAAAAAAECAwAEERIhMUEFExQiUSRhcf/EABYBAQEBAAAAAAAAAAAAAAAAAAIBA//EABcRAQEBAQAAAAAAAAAAAAAAAAEAAhH/2gAMAwEAAhEDEQA/AJ4vIe5DARMpHG3NaPj54HtzI7a3zsgxsPs0qO2iRhobSOxqzn978U6WC2lh0EIp41KBmga7a6EIze2pYqVxjsEEfzmsmTyP5cygFowcpgcjqr4LCCGQP6hkb6bBo7mCKRmLYBOPl3VdMcnb/9k=",
|
||||||
"/images/chua-thai-lac/20.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAQIFAAT/xAAkEAACAQMDBAMBAAAAAAAAAAABAgMABBEFEiETMUFxIlFhYv/EABUBAQEAAAAAAAAAAAAAAAAAAAAB/8QAGBEBAQADAAAAAAAAAAAAAAAAAQACEVH/2gAMAwEAAhEDEQA/AO6bVjbKDPDsUnAZW349jio0+oztemSJk6bN4zwPuqUmnQzbRliB/VONMtou0aj9zUVYHZ7u9PSjeBXXPLbozz6oLqkZChY3LngruwM+6ElvgIhmcIO6k5yKxtLRhkJ8gc5Hig5TRf/Z",
|
"/images/chua-thai-lac/20.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAQIFAAT/xAAkEAACAQMDBAMBAAAAAAAAAAABAgMABBEFEiETMUFxIlFhYv/EABUBAQEAAAAAAAAAAAAAAAAAAAAB/8QAGBEBAQADAAAAAAAAAAAAAAAAAQACEVH/2gAMAwEAAhEDEQA/AO6bVjbKDPDsUnAZW349jio0+oztemSJk6bN4zwPuqUmnQzbRliB/VONMtou0aj9zUVYHZ7u9PSjeBXXPLbozz6oLqkZChY3LngruwM+6ElvgIhmcIO6k5yKxtLRhkJ8gc5Hig5TRf/Z",
|
||||||
"/images/chua-thai-lac/21.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAID/8QAIhAAAgEEAgIDAQAAAAAAAAAAAQIDAAQRIRIxBUETFDKB/8QAFgEBAQEAAAAAAAAAAAAAAAAABAEC/8QAGBEAAwEBAAAAAAAAAAAAAAAAABESIRP/2gAMAwEAAhEDEQA/AMpLCyikdtMq6XA/Rotxaq7ZijVR0Me6uO2EkTqJ8nGgw0KTBbgyc/lRZSMZ6AFHt4J5rTCLx0Uh4SJwcAaHs1MfjLYuqknJbeegKZLEebs8o5dDjvIoU1pGwJ+w6nJJB7P9rTRJZ//Z",
|
"/images/chua-thai-lac/21.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAID/8QAIhAAAgEEAgIDAQAAAAAAAAAAAQIDAAQRIRIxBUETFDKB/8QAFgEBAQEAAAAAAAAAAAAAAAAABAEC/8QAGBEAAwEBAAAAAAAAAAAAAAAAABESIRP/2gAMAwEAAhEDEQA/AMpLCyikdtMq6XA/Rotxaq7ZijVR0Me6uO2EkTqJ8nGgw0KTBbgyc/lRZSMZ6AFHt4J5rTCLx0Uh4SJwcAaHs1MfjLYuqknJbeegKZLEebs8o5dDjvIoU1pGwJ+w6nJJB7P9rTRJZ//Z",
|
||||||
"/images/chua-thai-lac/22.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAABAP/xAAiEAACAQQCAgMBAAAAAAAAAAABAgMABBExEiETQWGBkaH/xAAWAQEBAQAAAAAAAAAAAAAAAAABAwT/xAAZEQEBAQADAAAAAAAAAAAAAAABAAIREzH/2gAMAwEAAhEDEQA/AIzXEnhwlvEGXZGDmi8JbkBiRGvyQKVPZwuV8cgQrvB3R5beXI4Trjvotkn+VI02hwHkV4WSZAUYq28mlZQkGGIDBxxY7+6qtlcOqiV+Sb67NVSxRAQWYD2Dv99UG0nq5v/Z",
|
"/images/chua-thai-lac/22.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAABAP/xAAiEAACAQQCAgMBAAAAAAAAAAABAgMABBExEiETQWGBkaH/xAAWAQEBAQAAAAAAAAAAAAAAAAABAwT/xAAZEQEBAQADAAAAAAAAAAAAAAABAAIREzH/2gAMAwEAAhEDEQA/AIzXEnhwlvEGXZGDmi8JbkBiRGvyQKVPZwuV8cgQrvB3R5beXI4Trjvotkn+VI02hwHkV4WSZAUYq28mlZQkGGIDBxxY7+6qtlcOqiV+Sb67NVSxRAQWYD2Dv99UG0nq5v/Z",
|
||||||
"/images/chua-thai-lac/23.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAgME/8QAIxAAAgEEAAYDAAAAAAAAAAAAAQIDAAQRIQUSMTJBURMikf/EABcBAAMBAAAAAAAAAAAAAAAAAAABAwT/xAAZEQACAwEAAAAAAAAAAAAAAAAAAQIREkH/2gAMAwEAAhEDEQA/ACJE2wim5R0OD0pxzLLN8SqqNnucHQ85oSWTsw5CwX0TmtlpaKA4cdw81FUaHskXRQAzIHzjAqN2qxrHKJ8c3cM6G8ftaJOEAn6ysN7yM69VR7GKK1ESgtvJJO6MrolOdn//2Q==",
|
"/images/chua-thai-lac/23.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAgME/8QAIxAAAgEEAAYDAAAAAAAAAAAAAQIDAAQRIQUSMTJBURMikf/EABcBAAMBAAAAAAAAAAAAAAAAAAABAwT/xAAZEQACAwEAAAAAAAAAAAAAAAAAAQIREkH/2gAMAwEAAhEDEQA/ACJE2wim5R0OD0pxzLLN8SqqNnucHQ85oSWTsw5CwX0TmtlpaKA4cdw81FUaHskXRQAzIHzjAqN2qxrHKJ8c3cM6G8ftaJOEAn6ysN7yM69VR7GKK1ESgtvJJO6MrolOdn//2Q==",
|
||||||
"/images/chua-thai-lac/24.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAMB/8QAIhAAAgEEAgIDAQAAAAAAAAAAAQIDAAQREiExE1EUInGx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAwAB/8QAGhEAAgMBAQAAAAAAAAAAAAAAAAECEkERIf/aAAwDAQACEQMRAD8AF5L1pmAJAxkBjilJLcQIkayCSY847B56o+gEqs10HUceuKS3xzNFNGRvEQwOeCaNSfPRHBYXW/vlXJjjEYOGYYOD6/alK90WEks4WJusD+it2thbNCFKh38hO+ftUDbW7RAJcmMnJYbd1tmVFp//2Q==",
|
"/images/chua-thai-lac/24.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAMB/8QAIhAAAgEEAgIDAQAAAAAAAAAAAQIDAAQREiExE1EUInGx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAwAB/8QAGhEAAgMBAQAAAAAAAAAAAAAAAAECEkERIf/aAAwDAQACEQMRAD8AF5L1pmAJAxkBjilJLcQIkayCSY847B56o+gEqs10HUceuKS3xzNFNGRvEQwOeCaNSfPRHBYXW/vlXJjjEYOGYYOD6/alK90WEks4WJusD+it2thbNCFKh38hO+ftUDbW7RAJcmMnJYbd1tmVFp//2Q==",
|
||||||
"/images/chua-thai-lac/25.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAwIE/8QAJBAAAgEEAQMFAQAAAAAAAAAAAQIDAAQRIRITMXEFFCJBQoH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAA//EABkRAQADAQEAAAAAAAAAAAAAAAEAAhESQf/aAAwDAQACEQMRAD8AOWNRbxlRKJydqdCoje5t0EjcXJ3wJzgeaeaCzCgK7Hee/ajmtldgIkbiR+c/Gpjb2U5I7+pzOFX28agjOdHX8rDcRXKyO3UyoOQeWiPFKLAIRzeUj6BGKZ7dG6ZkYFgSWxRs5BU2f//Z",
|
"/images/chua-thai-lac/25.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAwIE/8QAJBAAAgEEAQMFAQAAAAAAAAAAAQIDAAQRIRITMXEFFCJBQoH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAA//EABkRAQADAQEAAAAAAAAAAAAAAAEAAhESQf/aAAwDAQACEQMRAD8AOWNRbxlRKJydqdCoje5t0EjcXJ3wJzgeaeaCzCgK7Hee/ajmtldgIkbiR+c/Gpjb2U5I7+pzOFX28agjOdHX8rDcRXKyO3UyoOQeWiPFKLAIRzeUj6BGKZ7dG6ZkYFgSWxRs5BU2f//Z",
|
||||||
"/images/chua-thai-lac/26.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAwQBAgX/xAAjEAACAgICAgEFAAAAAAAAAAABAgMRAAQSIRMUBSIjJDJh/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAP/xAAYEQADAQEAAAAAAAAAAAAAAAAAAQISEf/aAAwDAQACEQMRAD8AC/togWGZpZWPSEUBh4/kdyG1j4OFq2YgViiPohgW2mYHo2xwPlgUhX2PtH9lFWTdjJp0UzJpS7W3wb2x4/qKWpHRq8Qlj3omDNOHH8Y5abc053X8jnbciG6s1VnJl2dWRI4jOvJBTMGq8N1wZk//2Q==",
|
"/images/chua-thai-lac/26.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAwQBAgX/xAAjEAACAgICAgEFAAAAAAAAAAABAgMRAAQSIRMUBSIjJDJh/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAP/xAAYEQADAQEAAAAAAAAAAAAAAAAAAQISEf/aAAwDAQACEQMRAD8AC/togWGZpZWPSEUBh4/kdyG1j4OFq2YgViiPohgW2mYHo2xwPlgUhX2PtH9lFWTdjJp0UzJpS7W3wb2x4/qKWpHRq8Qlj3omDNOHH8Y5abc053X8jnbciG6s1VnJl2dWRI4jOvJBTMGq8N1wZk//2Q==",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
// File chỉ chứa DỮ LIỆU (type-only import từ engine để tránh circular import runtime).
|
// File chỉ chứa DỮ LIỆU (type-only import từ engine để tránh circular import runtime).
|
||||||
// Engine `VirtualTour.tsx` import `chuaThaiLacTour` rồi bọc qua createTourConfig().
|
// Engine `VirtualTour.tsx` import `chuaThaiLacTour` rồi bọc qua createTourConfig().
|
||||||
//
|
//
|
||||||
// Ảnh panorama đặt tại public/images/chua-thai-lac/01.jpg … 26.jpg
|
// Ảnh panorama đặt tại public/images/chua-thai-lac/01.webp … 26.webp
|
||||||
// (đặt tên ASCII theo thứ tự cảnh để tránh lỗi mã hóa Unicode/đường dẫn khi triển khai).
|
// (đặt tên ASCII theo thứ tự cảnh để tránh lỗi mã hóa Unicode/đường dẫn khi triển khai).
|
||||||
import type { TourConfig, TourScene, InfoMarker, Hotspot } from "@/app/components/VirtualTour";
|
import type { TourConfig, TourScene, InfoMarker, Hotspot } from "@/app/components/VirtualTour";
|
||||||
|
|
||||||
const base = "/images/chua-thai-lac";
|
const base = "/images/chua-thai-lac";
|
||||||
// Trỏ tới đúng file ảnh gốc theo SỐ FILE (01..26), độc lập với thứ tự cảnh sau khi
|
// Trỏ tới đúng file ảnh gốc theo SỐ FILE (01..26), độc lập với thứ tự cảnh sau khi
|
||||||
// đánh số lại — nhờ vậy có thể bỏ cảnh giữa chừng mà không phải đổi tên ảnh.
|
// đánh số lại — nhờ vậy có thể bỏ cảnh giữa chừng mà không phải đổi tên ảnh.
|
||||||
const imageForFile = (fileNumber: number) => `${base}/${String(fileNumber).padStart(2, "0")}.jpg`;
|
const imageForFile = (fileNumber: number) => `${base}/${String(fileNumber).padStart(2, "0")}.webp`;
|
||||||
|
|
||||||
type SceneSeed = {
|
type SceneSeed = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -408,7 +408,7 @@ export const chuaThaiLacTour: Omit<TourConfig, "sceneById" | "locationGroups"> =
|
|||||||
welcomeAccent: "Pháp Vân tự",
|
welcomeAccent: "Pháp Vân tự",
|
||||||
welcomeSubtitle:
|
welcomeSubtitle:
|
||||||
"Tham quan 360° ngôi chùa cổ thờ Pháp Vân, nổi tiếng với mảng chạm khắc gỗ thời Trần.",
|
"Tham quan 360° ngôi chùa cổ thờ Pháp Vân, nổi tiếng với mảng chạm khắc gỗ thời Trần.",
|
||||||
welcomeBackgroundImage: imageForFile(8), // 08.jpg — Chính sảnh
|
welcomeBackgroundImage: imageForFile(8), // 08.webp — Chính sảnh
|
||||||
welcomeCards: [
|
welcomeCards: [
|
||||||
{ image: imageForFile(1), label: "Cổng chùa", rotate: "-rotate-6", sceneId: "scene-1" },
|
{ image: imageForFile(1), label: "Cổng chùa", rotate: "-rotate-6", sceneId: "scene-1" },
|
||||||
{ image: imageForFile(10), label: "Đài Quan Âm", rotate: "rotate-5", sceneId: "scene-10" },
|
{ image: imageForFile(10), label: "Đài Quan Âm", rotate: "rotate-5", sceneId: "scene-10" },
|
||||||
|
|||||||