Files
gitlab-instance-0a899031_cn…/app/digital/components/ResourceNavigation.tsx
root a1daa040ef s
2026-06-07 18:06:52 +08:00

279 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "@/app/digital/i18n/navigation";
import type { ResourceData } from "@/app/digital/lib/theme-data";
import DigitalThemeIcon, { type IconName } from "./DigitalThemeIcon";
const PER_PAGE_MOBILE = 4;
const PER_PAGE_PC = 8;
const BREAKPOINT = 768;
const panCategoryIconNames: Record<string, IconName> = {
docs: "file",
templates: "check",
firmware: "spark",
storage: "cloud",
};
function useIsPC() {
return useSyncExternalStore(
(callback) => {
if (typeof window === "undefined") return () => {};
const mq = window.matchMedia(`(min-width: ${BREAKPOINT}px)`);
const fn = () => callback();
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
},
() =>
typeof window !== "undefined" &&
window.matchMedia(`(min-width: ${BREAKPOINT}px)`).matches,
() => false
);
}
/** 每页条数H5=4PC=8总数不足则全显 */
function getPerPage(total: number, isPC: boolean): number {
const cap = isPC ? PER_PAGE_PC : PER_PAGE_MOBILE;
return total <= cap ? total : cap;
}
type ResourceNavigationProps = {
data: ResourceData;
};
export default function ResourceNavigation({ data }: ResourceNavigationProps) {
const { t } = useTranslation("community");
const isPC = useIsPC();
const firstCategoryId = data.pan.categories[0]?.id ?? "";
const [activePanCategory, setActivePanCategory] = useState(firstCategoryId);
const [booksPage, setBooksPage] = useState(0);
const [productsPage, setProductsPage] = useState(0);
const activeCategory = data.pan.categories.find((c) => c.id === activePanCategory);
const booksPerPage = getPerPage(data.books.length, isPC);
const productsPerPage = getPerPage(data.products.length, isPC);
const booksTotalPages = Math.ceil(data.books.length / booksPerPage) || 1;
const productsTotalPages = Math.ceil(data.products.length / productsPerPage) || 1;
const safeBooksPage = Math.max(0, Math.min(booksPage, booksTotalPages - 1));
const safeProductsPage = Math.max(0, Math.min(productsPage, productsTotalPages - 1));
const paginatedBooks = useMemo(() => {
const start = safeBooksPage * booksPerPage;
return data.books.slice(start, start + booksPerPage);
}, [data.books, safeBooksPage, booksPerPage]);
const paginatedProducts = useMemo(() => {
const start = safeProductsPage * productsPerPage;
return data.products.slice(start, start + productsPerPage);
}, [data.products, safeProductsPage, productsPerPage]);
return (
<div className="dn-resources mt-12 space-y-16">
{/* 图书 - 封面+书名,数量少则大封面,多则分页 */}
<div>
<h3 className="dn-subsection-title mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<DigitalThemeIcon name="book" emoji="📚" className="h-5 w-5" />
{t("resourceSections.books")}
{data.books.length > 0 && (
<span className="text-sm font-normal text-slate-500 dark:text-slate-400">
{t("resourceSections.totalBooks").replace("{count}", String(data.books.length))}
</span>
)}
</h3>
<div
className="grid grid-cols-2 gap-3 gap-y-4 sm:gap-4 md:grid-cols-8"
>
{paginatedBooks.map((book, i) => (
<a
key={`book-${booksPage}-${i}`}
href={book.href}
target="_blank"
rel="noopener noreferrer sponsored"
className="dn-resource-tile group flex cursor-pointer flex-col text-left focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-slate-50 dark:focus:ring-offset-slate-950"
>
<div
className="overflow-hidden rounded-lg border border-slate-200 bg-slate-100 shadow-sm transition-all group-hover:shadow-md dark:border-slate-700 dark:bg-slate-800"
style={{ aspectRatio: "2/3" }}
>
<img
src={book.cover}
alt={book.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<p className="mt-2 line-clamp-2 text-center text-xs font-medium text-slate-700 dark:text-slate-300 sm:text-sm">
{book.title}
</p>
</a>
))}
</div>
{booksTotalPages > 1 && (
<div className="dn-pagination mt-6 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setBooksPage((p) => Math.max(0, p - 1))}
disabled={safeBooksPage === 0}
className="dn-icon-button flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="上一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="px-4 text-sm text-slate-600 dark:text-slate-400">
{safeBooksPage + 1} / {booksTotalPages}
</span>
<button
type="button"
onClick={() => setBooksPage((p) => Math.min(booksTotalPages - 1, p + 1))}
disabled={safeBooksPage === booksTotalPages - 1}
className="dn-icon-button flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="下一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
{/* 商业数码 - 商品图+跳转电商 */}
<div>
<h3 className="dn-subsection-title mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<DigitalThemeIcon name="shopping" emoji="🛒" className="h-5 w-5" />
{t("resourceSections.products")}
{data.products.length > 0 && (
<span className="text-sm font-normal text-slate-500 dark:text-slate-400">
{t("resourceSections.totalProducts").replace("{count}", String(data.products.length))}
</span>
)}
</h3>
<div
className="grid grid-cols-2 gap-3 gap-y-4 sm:gap-4 md:grid-cols-8"
>
{paginatedProducts.map((product, i) => (
<a
key={`product-${productsPage}-${i}`}
href={product.href}
target="_blank"
rel="noopener noreferrer sponsored"
className="dn-resource-tile group flex cursor-pointer flex-col text-left focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-slate-50 dark:focus:ring-offset-slate-950"
>
<div
className="overflow-hidden rounded-xl border border-slate-200 bg-white p-3 shadow-sm transition-all group-hover:shadow-md dark:border-slate-700 dark:bg-slate-900"
style={{ aspectRatio: "1/1" }}
>
<img
src={product.image}
alt={product.name}
className="h-full w-full object-contain transition-transform duration-300 group-hover:scale-105"
/>
</div>
<p className="mt-2 line-clamp-2 text-center text-xs font-medium text-slate-700 dark:text-slate-300 sm:text-sm">
{product.name}
</p>
</a>
))}
</div>
{productsTotalPages > 1 && (
<div className="dn-pagination mt-6 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setProductsPage((p) => Math.max(0, p - 1))}
disabled={safeProductsPage === 0}
className="dn-icon-button flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="上一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="px-4 text-sm text-slate-600 dark:text-slate-400">
{safeProductsPage + 1} / {productsTotalPages}
</span>
<button
type="button"
onClick={() => setProductsPage((p) => Math.min(productsTotalPages - 1, p + 1))}
disabled={safeProductsPage === productsTotalPages - 1}
className="dn-icon-button flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="下一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
{/* 网盘 - 分类 Tab */}
<div>
<h3 className="dn-subsection-title mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<DigitalThemeIcon name="cloud" emoji="☁️" className="h-5 w-5" />
{t("resourceSections.pan")}
</h3>
<div className="dn-card dn-pan rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
<div className="dn-tabs flex flex-wrap gap-1 border-b border-slate-200 p-2 dark:border-slate-700">
{data.pan.categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => setActivePanCategory(cat.id)}
className={`dn-tab rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
activePanCategory === cat.id
? "dn-tab-active bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
>
<DigitalThemeIcon
name={panCategoryIconNames[cat.id] ?? "file"}
emoji={cat.icon}
className="mr-1.5 inline h-4 w-4"
/>
{cat.title}
</button>
))}
</div>
<div className="p-4">
{activeCategory && (
<div
className="grid gap-3"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 260px), 1fr))",
}}
>
{activeCategory.items.map((item, i) => (
<a
key={i}
href={item.href}
target="_blank"
rel="noopener noreferrer sponsored"
className="dn-resource-link flex items-center gap-3 rounded-xl border border-slate-100 p-3 transition-colors hover:border-sky-200 hover:bg-sky-50/50 dark:border-slate-700 dark:hover:border-sky-800 dark:hover:bg-sky-900/20"
>
<div className="dn-icon-box flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-lg dark:bg-slate-800">
<DigitalThemeIcon
name={panCategoryIconNames[activeCategory.id] ?? "file"}
emoji={activeCategory.icon}
className="h-5 w-5"
/>
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-900 dark:text-slate-100">{item.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-400">{item.desc}</div>
</div>
<span className="shrink-0 text-sm font-medium text-sky-600"></span>
</a>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}