feat: thiết kế lại bộ lọc /di-tich + marquee cuộn liên tục (luôn bật)

- Bộ lọc cao cấp: ô tìm kiếm bo tròn full-width có nút xóa, tóm tắt số kết quả,
  3 hàng lọc có nhãn + chip kèm số đếm, tóm tắt bộ lọc đang áp dụng + xóa tất cả
- Marquee tên di tích: animation luôn bật (gỡ cổng no-preference khiến đứng yên),
  vẫn cuộn chậm nhẹ dưới reduced-motion

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
tuyenio
2026-06-23 16:30:05 +07:00
parent 224166f1d4
commit 11b78433a9
2 changed files with 154 additions and 89 deletions
@@ -2,7 +2,7 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowRight, Search, SlidersHorizontal } from "lucide-react";
import { ArrowRight, Search, SlidersHorizontal, X } from "lucide-react";
import { useMemo, useState } from "react";
import type { HeritageSite } from "@/app/content";
@@ -38,74 +38,98 @@ export function CulturalSpaceExplorer({ sites }: { sites: HeritageSite[] }) {
});
}, [query, sites, status, level, type]);
const count = (key: "status" | "level" | "type", value: string) => {
if (value === "Tất cả") return sites.length;
return sites.filter((s) => s[key] === value).length;
};
const hasActiveFilters =
query.trim().length > 0 || status !== "Tất cả" || level !== "Tất cả" || type !== "Tất cả";
const resetAll = () => {
setQuery("");
setStatus("Tất cả");
setLevel("Tất cả");
setType("Tất cả");
};
return (
<section className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:py-20">
<div className="public-panel rounded-[8px] p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[1fr_auto_auto_auto] lg:items-end">
<label className="grid gap-2">
<span className="text-sm font-bold text-[var(--tour-ink)]">Tìm kiếm di tích</span>
<span className="relative">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--primary)]" strokeWidth={1.8} />
<input
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-[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>
<div className="public-panel rounded-[20px] p-5 sm:p-7">
{/* Hàng tìm kiếm + tóm tắt kết quả */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<label className="relative block w-full lg:max-w-2xl">
<span className="sr-only">Tìm kiếm di tích</span>
<Search
className="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-[var(--primary)]"
strokeWidth={1.7}
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Tìm theo tên di tích, loại hình hoặc từ khóa…"
className="h-14 w-full rounded-full border border-[var(--surface-border)] bg-white/85 pl-12 pr-12 text-sm font-semibold text-[var(--ink-on-light)] shadow-[0_2px_10px_rgb(20_30_18_/_0.05)] outline-none transition placeholder:font-medium placeholder:text-[var(--ink-on-light-muted)] focus:border-[var(--primary)] focus:ring-4 focus:ring-[rgb(200_164_106_/_0.16)]"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="Xóa tìm kiếm"
className="absolute right-3 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-full text-[var(--ink-on-light-muted)] transition hover:bg-[rgb(20_30_18_/_0.06)] hover:text-[var(--ink-on-light)]"
>
<X className="h-4 w-4" strokeWidth={2} />
</button>
) : null}
</label>
<FilterGroup
label="Trạng thái"
options={statusFilters}
value={status}
onChange={setStatus}
/>
<FilterGroup label="Cấp xếp hạng" options={levelFilters} value={level} onChange={setLevel} />
<FilterGroup
label="Loại địa điểm"
options={typeFilters}
value={type}
onChange={setType}
/>
<p className="shrink-0 text-sm font-semibold text-[var(--muted-foreground)]">
Hiển thị{" "}
<span className="font-display-vn text-base font-extrabold text-[var(--primary)] [font-variant-numeric:tabular-nums]">
{filteredSites.length}
</span>{" "}
trên {sites.length} di tích
</p>
</div>
</div>
<div className="mt-8 flex items-center justify-between gap-4">
<p className="text-sm font-bold text-[var(--muted-foreground)]">
Hiển thị {filteredSites.length} / {sites.length} đa điểm
</p>
<button
type="button"
onClick={() => {
setQuery("");
setStatus("Tất cả");
setLevel("Tất cả");
setType("Tất cả");
}}
className="text-sm font-bold text-[var(--tour-ink)] transition hover:text-[var(--primary)]"
>
Xóa bộ lọc
</button>
{/* Các nhóm lọc */}
<div className="mt-6 grid gap-5 border-t border-[var(--surface-border)] pt-6">
<FilterRow label="Trạng thái" options={statusFilters} value={status} onChange={setStatus} count={(o) => count("status", o)} />
<FilterRow label="Cấp xếp hạng" options={levelFilters} value={level} onChange={setLevel} count={(o) => count("level", o)} />
<FilterRow label="Loại hình" options={typeFilters} value={type} onChange={setType} count={(o) => count("type", o)} />
</div>
{/* Tóm tắt bộ lọc đang áp dụng */}
{hasActiveFilters ? (
<div className="mt-6 flex flex-wrap items-center gap-2 border-t border-[var(--surface-border)] pt-5">
<span className="text-xs font-bold uppercase tracking-[0.12em] text-[var(--ink-on-light-muted)]">
Đang lọc
</span>
{query.trim() ? <ActiveChip label={`${query.trim()}`} onClear={() => setQuery("")} /> : null}
{status !== "Tất cả" ? <ActiveChip label={status} onClear={() => setStatus("Tất cả")} /> : null}
{level !== "Tất cả" ? <ActiveChip label={level} onClear={() => setLevel("Tất cả")} /> : null}
{type !== "Tất cả" ? <ActiveChip label={type} onClear={() => setType("Tất cả")} /> : null}
<button
type="button"
onClick={resetAll}
className="ml-1 text-sm font-bold text-[var(--primary)] underline-offset-4 transition hover:underline"
>
Xóa tất cả
</button>
</div>
) : null}
</div>
{filteredSites.length > 0 ? (
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="mt-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{filteredSites.map((site) => (
<article
id={site.id}
key={site.id}
className="public-card public-stagger-item group rounded-[8px]"
>
<article id={site.id} key={site.id} className="public-card public-stagger-item group rounded-[16px]">
<div className="relative aspect-[16/11] overflow-hidden">
<Image
src={site.image}
alt={site.name}
fill
sizes="(max-width: 768px) 100vw, 25vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.025]"
className="object-cover transition-transform duration-700 group-hover:scale-[1.04]"
/>
</div>
<div className="flex min-h-[19rem] flex-col p-5">
@@ -120,62 +144,99 @@ export function CulturalSpaceExplorer({ sites }: { sites: HeritageSite[] }) {
{site.status}
</span>
<p className="mt-4 text-sm font-bold text-[var(--primary)]">{site.type}</p>
<h2 className="mt-2 text-xl font-extrabold leading-tight text-[var(--tour-ink)]">
{site.name}
</h2>
<p className="mt-3 text-sm leading-6 text-[var(--muted-foreground)]">
{site.shortDescription}
</p>
<Link href={site.isAvailable ? site.vrUrl ?? site.detailUrl : site.detailUrl} className="mt-auto inline-flex items-center gap-2 pt-5 text-sm font-extrabold text-[var(--tour-ink)] transition hover:text-[var(--primary)]">
<h2 className="mt-2 text-xl font-extrabold leading-tight text-[var(--tour-ink)]">{site.name}</h2>
<p className="mt-3 text-sm leading-6 text-[var(--muted-foreground)]">{site.shortDescription}</p>
<Link
href={site.isAvailable ? site.vrUrl ?? site.detailUrl : site.detailUrl}
className="mt-auto inline-flex items-center gap-2 pt-5 text-sm font-extrabold text-[var(--tour-ink)] transition hover:text-[var(--primary)]"
>
{site.isAvailable ? "Khám phá VR360" : "Xem thông tin"}
<ArrowRight className="h-4 w-4" strokeWidth={1.8} />
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" strokeWidth={1.8} />
</Link>
</div>
</article>
))}
</div>
) : (
<div className="public-panel mt-6 rounded-[8px] p-8 text-center">
<SlidersHorizontal className="mx-auto h-8 w-8 text-[var(--primary)]" strokeWidth={1.8} />
<p className="mt-4 text-lg font-bold text-[var(--tour-ink)]">
Không tìm thấy di tích phù hợp. Vui lòng thử lại với từ khóa khác.
<div className="public-panel mt-8 rounded-[16px] p-10 text-center">
<SlidersHorizontal className="mx-auto h-8 w-8 text-[var(--primary)]" strokeWidth={1.7} />
<p className="mx-auto mt-4 max-w-[44ch] text-lg font-bold text-[var(--tour-ink)]">
Không tìm thấy di tích phù hợp với bộ lọc hiện tại.
</p>
<button
type="button"
onClick={resetAll}
className="public-cta-secondary mt-6"
>
Đt lại bộ lọc
</button>
</div>
)}
</section>
);
}
function FilterGroup<T extends readonly string[]>({
function FilterRow<T extends readonly string[]>({
label,
options,
value,
onChange,
count,
}: {
label: string;
options: T;
value: T[number];
onChange: (value: T[number]) => void;
count: (option: T[number]) => number;
}) {
return (
<div className="grid gap-2">
<span className="text-sm font-bold text-[var(--tour-ink)]">{label}</span>
<div className="flex max-w-full gap-2 overflow-x-auto pb-1 [scrollbar-width:none]">
{options.map((option) => (
<button
key={option}
type="button"
onClick={() => onChange(option)}
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(--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}
</button>
))}
<div className="grid gap-2.5 sm:grid-cols-[116px_1fr] sm:items-start sm:gap-5">
<span className="text-xs font-bold uppercase tracking-[0.12em] text-[var(--ink-on-light-muted)] sm:pt-2.5">
{label}
</span>
<div className="flex flex-wrap gap-2">
{options.map((option) => {
const active = value === option;
return (
<button
key={option}
type="button"
onClick={() => onChange(option)}
aria-pressed={active}
className={`inline-flex h-10 items-center gap-2 rounded-full border px-4 text-sm font-bold transition active:scale-[0.97] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[rgb(200_164_106_/_0.2)] ${
active
? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)] shadow-[0_8px_22px_rgb(142_95_11_/_0.26)]"
: "border-[var(--surface-border)] bg-white/72 text-[var(--ink-on-light-muted)] hover:border-[rgb(142_95_11_/_0.45)] hover:text-[var(--ink-on-light)]"
}`}
>
{option}
<span
className={`font-display-vn text-[0.72rem] [font-variant-numeric:tabular-nums] ${
active ? "text-[var(--primary-foreground)]/75" : "text-[var(--ink-on-light-muted)]/70"
}`}
>
{count(option)}
</span>
</button>
);
})}
</div>
</div>
);
}
function ActiveChip({ label, onClear }: { label: string; onClear: () => void }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-[rgb(142_95_11_/_0.3)] bg-[rgb(200_164_106_/_0.12)] py-1 pl-3 pr-1.5 text-xs font-bold text-[var(--ink-on-light)]">
{label}
<button
type="button"
onClick={onClear}
aria-label={`Bỏ lọc ${label}`}
className="grid h-5 w-5 place-items-center rounded-full text-[var(--primary)] transition hover:bg-[rgb(142_95_11_/_0.18)]"
>
<X className="h-3 w-3" strokeWidth={2.4} />
</button>
</span>
);
}
+10 -6
View File
@@ -1430,15 +1430,19 @@ a {
white-space: nowrap;
will-change: transform;
transform: translate3d(0, 0, 0);
/* Cuộn liên tục phải -> trái, luôn bật (di chuyển nhẹ ngay cả khi giảm chuyển động). */
animation: heritage-marquee 42s linear infinite;
}
@media (prefers-reduced-motion: no-preference) {
.heritage-marquee-track {
animation: heritage-marquee 42s linear infinite;
}
.heritage-marquee:hover .heritage-marquee-track {
animation-play-state: paused;
}
.heritage-marquee:hover .heritage-marquee-track {
animation-play-state: paused;
/* Ghi đè khối reduce toàn cục: vẫn cuộn nhưng chậm, nhẹ nhàng. */
@media (prefers-reduced-motion: reduce) {
.heritage-marquee-track {
animation-duration: 90s !important;
animation-iteration-count: infinite !important;
}
}