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:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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 { useMemo, useState } from "react";
|
||||||
import type { HeritageSite } from "@/app/content";
|
import type { HeritageSite } from "@/app/content";
|
||||||
|
|
||||||
@@ -38,74 +38,98 @@ export function CulturalSpaceExplorer({ sites }: { sites: HeritageSite[] }) {
|
|||||||
});
|
});
|
||||||
}, [query, sites, status, level, type]);
|
}, [query, sites, status, level, type]);
|
||||||
|
|
||||||
return (
|
const count = (key: "status" | "level" | "type", value: string) => {
|
||||||
<section className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:py-20">
|
if (value === "Tất cả") return sites.length;
|
||||||
<div className="public-panel rounded-[8px] p-4 sm:p-5">
|
return sites.filter((s) => s[key] === value).length;
|
||||||
<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>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<FilterGroup
|
const hasActiveFilters =
|
||||||
label="Trạng thái"
|
query.trim().length > 0 || status !== "Tất cả" || level !== "Tất cả" || type !== "Tất cả";
|
||||||
options={statusFilters}
|
|
||||||
value={status}
|
|
||||||
onChange={setStatus}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterGroup label="Cấp xếp hạng" options={levelFilters} value={level} onChange={setLevel} />
|
const resetAll = () => {
|
||||||
|
|
||||||
<FilterGroup
|
|
||||||
label="Loại địa điểm"
|
|
||||||
options={typeFilters}
|
|
||||||
value={type}
|
|
||||||
onChange={setType}
|
|
||||||
/>
|
|
||||||
</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("");
|
setQuery("");
|
||||||
setStatus("Tất cả");
|
setStatus("Tất cả");
|
||||||
setLevel("Tất cả");
|
setLevel("Tất cả");
|
||||||
setType("Tất cả");
|
setType("Tất cả");
|
||||||
}}
|
};
|
||||||
className="text-sm font-bold text-[var(--tour-ink)] transition hover:text-[var(--primary)]"
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:py-20">
|
||||||
|
<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óa bộ lọc
|
<X className="h-4 w-4" strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
{filteredSites.length > 0 ? (
|
{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) => (
|
{filteredSites.map((site) => (
|
||||||
<article
|
<article id={site.id} key={site.id} className="public-card public-stagger-item group rounded-[16px]">
|
||||||
id={site.id}
|
|
||||||
key={site.id}
|
|
||||||
className="public-card public-stagger-item group rounded-[8px]"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-[16/11] overflow-hidden">
|
<div className="relative aspect-[16/11] overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={site.image}
|
src={site.image}
|
||||||
alt={site.name}
|
alt={site.name}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 768px) 100vw, 25vw"
|
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>
|
||||||
<div className="flex min-h-[19rem] flex-col p-5">
|
<div className="flex min-h-[19rem] flex-col p-5">
|
||||||
@@ -120,62 +144,99 @@ export function CulturalSpaceExplorer({ sites }: { sites: HeritageSite[] }) {
|
|||||||
{site.status}
|
{site.status}
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-4 text-sm font-bold text-[var(--primary)]">{site.type}</p>
|
<p className="mt-4 text-sm font-bold text-[var(--primary)]">{site.type}</p>
|
||||||
<h2 className="mt-2 text-xl font-extrabold leading-tight text-[var(--tour-ink)]">
|
<h2 className="mt-2 text-xl font-extrabold leading-tight text-[var(--tour-ink)]">{site.name}</h2>
|
||||||
{site.name}
|
<p className="mt-3 text-sm leading-6 text-[var(--muted-foreground)]">{site.shortDescription}</p>
|
||||||
</h2>
|
<Link
|
||||||
<p className="mt-3 text-sm leading-6 text-[var(--muted-foreground)]">
|
href={site.isAvailable ? site.vrUrl ?? site.detailUrl : site.detailUrl}
|
||||||
{site.shortDescription}
|
className="mt-auto inline-flex items-center gap-2 pt-5 text-sm font-extrabold text-[var(--tour-ink)] transition hover:text-[var(--primary)]"
|
||||||
</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"}
|
{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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="public-panel mt-6 rounded-[8px] p-8 text-center">
|
<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.8} />
|
<SlidersHorizontal className="mx-auto h-8 w-8 text-[var(--primary)]" strokeWidth={1.7} />
|
||||||
<p className="mt-4 text-lg font-bold text-[var(--tour-ink)]">
|
<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. Vui lòng thử lại với từ khóa khác.
|
Không tìm thấy di tích phù hợp với bộ lọc hiện tại.
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetAll}
|
||||||
|
className="public-cta-secondary mt-6"
|
||||||
|
>
|
||||||
|
Đặt lại bộ lọc
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterGroup<T extends readonly string[]>({
|
function FilterRow<T extends readonly string[]>({
|
||||||
label,
|
label,
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
count,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
options: T;
|
options: T;
|
||||||
value: T[number];
|
value: T[number];
|
||||||
onChange: (value: T[number]) => void;
|
onChange: (value: T[number]) => void;
|
||||||
|
count: (option: T[number]) => number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2.5 sm:grid-cols-[116px_1fr] sm:items-start sm:gap-5">
|
||||||
<span className="text-sm font-bold text-[var(--tour-ink)]">{label}</span>
|
<span className="text-xs font-bold uppercase tracking-[0.12em] text-[var(--ink-on-light-muted)] sm:pt-2.5">
|
||||||
<div className="flex max-w-full gap-2 overflow-x-auto pb-1 [scrollbar-width:none]">
|
{label}
|
||||||
{options.map((option) => (
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = value === option;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={option}
|
key={option}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(option)}
|
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)] ${
|
aria-pressed={active}
|
||||||
value === option
|
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)] ${
|
||||||
? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)] shadow-[0_8px_20px_rgb(142_95_11_/_0.28)]"
|
active
|
||||||
: "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)]"
|
? "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}
|
{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>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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
@@ -1430,15 +1430,19 @@ a {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
transform: translate3d(0, 0, 0);
|
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:hover .heritage-marquee-track {
|
||||||
.heritage-marquee-track {
|
|
||||||
animation: heritage-marquee 42s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heritage-marquee:hover .heritage-marquee-track {
|
|
||||||
animation-play-state: paused;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user