import { Empty, Layout, Notice } from '@stash/ui' import { useEffect, useMemo, useRef, useState } from 'react' import { BackToTop } from './components/common/BackToTop' import { ArticleDetailView } from './components/content/ArticleDetailView' import { DetailView } from './components/content/DetailView' import { HomeView } from './components/content/HomeView' import { ItemDetailView } from './components/content/ItemDetailView' import { SiteHeader } from './components/header/SiteHeader' import { SearchBar } from './components/search/SearchBar' import { Sidebar } from './components/sidebar/Sidebar' import { withLocalCategory } from './data/local' import { appDetailModel, posterDetailModel, posterToRelatedItem, toRelatedItem, } from './data/detail' import { filterCategories } from './data/filter' import type { NavTarget } from './data/nav' import { type Category, findAppItem, findArticleItem, findPosterItem, relatedAppItems, relatedPosterItems, } from './data/types' import { useProjects } from './useProjects' import { useCatalog } from './useCatalog' import { useViewRoute } from './useViewRoute' import type { ItemRoute, View } from './data/view' import './App.css' export default function App() { const { status, projects, error, live, refreshing, refresh } = useProjects() // 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成 const catalog = useCatalog() const [keyword, setKeyword] = useState('') // 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts) const { view, go, back } = useViewRoute() const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) const categories = useMemo( () => withLocalCategory(projects, catalog.categories), [projects, catalog.categories], ) const visibleCategories = useMemo( () => filterCategories(categories, keyword), [categories, keyword], ) // 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区 const sidebarCategories = useMemo( () => categories.filter( (category) => category.id !== 'local' && category.showInSidebar !== false, ), [categories], ) const detailView = view.type === 'detail' ? view : undefined const itemView = view.type === 'item' ? view : undefined const detailCategory = detailView !== undefined ? categories.find((category) => category.id === detailView.categoryId) : undefined const itemCategory = itemView !== undefined ? categories.find((category) => category.id === itemView.categoryId) : undefined // 应用与电影共用一个详情模型,这里按分类类型做映射 const detailModel = useMemo(() => { if (itemView === undefined || itemCategory === undefined) return undefined if (itemCategory.kind === 'apps') { const item = findAppItem(itemCategory, itemView.itemId) return item !== undefined ? appDetailModel(item) : undefined } if (itemCategory.kind === 'posters') { const poster = findPosterItem(itemCategory, itemView.itemId) return poster !== undefined ? posterDetailModel(poster) : undefined } return undefined }, [itemView, itemCategory]) const relatedItems = useMemo(() => { if (itemView === undefined || itemCategory === undefined) return [] if (itemCategory.kind === 'apps') { return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem) } if (itemCategory.kind === 'posters') { return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem) } return [] }, [itemView, itemCategory]) // 切换视图时把内容区(内部滚动)和窗口都回到顶部 useEffect(() => { const scroller = document.getElementById('content-scroll') scroller?.scrollTo({ top: 0 }) window.scrollTo({ top: 0 }) }, [view]) /** * 滚动状态同时驱动「顶部渐隐」和「回到顶部」。 * 桌面端滚的是内容容器(scroll 事件不冒泡),所以用捕获阶段监听。 */ useEffect(() => { const sync = () => { const inner = document.getElementById('content-scroll')?.scrollTop ?? 0 const next = Math.max(inner, window.scrollY) > 4 setScrolled((current) => (current === next ? current : next)) } sync() window.addEventListener('scroll', sync, { passive: true }) document.addEventListener('scroll', sync, { passive: true, capture: true }) return () => { window.removeEventListener('scroll', sync) document.removeEventListener('scroll', sync, { capture: true }) } }, []) /** * 「打开这个分区」应该落到哪个视图。 * * 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。 */ const detailViewFor = (categoryId: string): View => { const category = categories.find((item) => item.id === categoryId) if (category?.kind === 'apps' && category.block !== undefined) { const group = category.groups.find((item) => item.id === category.block?.nodeKey) if (group !== undefined) { return { type: 'detail', categoryId, groupId: group.id } } } return { type: 'detail', categoryId } } const openDetail = (categoryId: string) => go(detailViewFor(categoryId)) /** 条目所在的分组:应用按组组织,海报 / 图文直接挂在分区下 */ const groupOfItem = (category: Category, itemId: string): string | undefined => { if (category.kind !== 'apps') return undefined return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id } const openItem = (categoryId: string, itemId: string) => { const category = categories.find((item) => item.id === categoryId) go({ type: 'item', categoryId, itemId, // 地址里带上条目自己的分组,刷新后「返回」才有确定的落点 groupId: category === undefined ? undefined : groupOfItem(category, itemId), }) } /** * 条目详情的「返回」目标:优先回到该条目所在的分组, * 没有分组(海报 / 图文)时回到分区,走和侧栏一样的展示块规则。 */ const detailTargetFor = (item: ItemRoute): View => item.groupId === undefined ? detailViewFor(item.categoryId) : { type: 'detail', categoryId: item.categoryId, groupId: item.groupId } /** * 侧栏导航:带分组时直接打开那个分组; * 点分区本身时,交给 openDetail 按展示块落到对应节点。 */ const navigate = (target: NavTarget) => { if (target.groupId === undefined) { openDetail(target.categoryId) return } go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId }) } const goHome = () => go({ type: 'home' }) const searching = keyword.trim() !== '' const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId /** * 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」, * 不能直接报「不存在」——刷新详情页时最容易被这个误导。 */ const dataPending = viewCategoryId !== undefined && (catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading')) /** * 条目详情按分类类型分流: * 图文走长文版式,应用 / 电影走通用的条目版式。 */ function renderItemView() { if (itemView === undefined) return null if (itemCategory === undefined) { return } if (itemCategory.kind === 'articles') { const article = findArticleItem(itemCategory, itemView.itemId) if (article === undefined) { return } return ( back(detailTargetFor(itemView))} /> ) } if (detailModel === undefined) { return } return ( back(detailTargetFor(itemView))} onOpenItem={(itemId) => openItem(itemCategory.id, itemId)} /> ) } return ( <> {/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}