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 { fetchSession, logout as logoutRequest, type SessionState } from './auth' import { applySidebarSettings, defaultSettings, fetchSettings, type SettingsDocument, } from './settings' import { LoginDialog } from './components/auth/LoginDialog' import { ItemEditor } from './components/content/ItemEditor' import { parseViewPath, viewPath, type 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 } = useViewRoute() const [session, setSession] = useState(null) const [settings, setSettings] = useState({ version: 0, settings: defaultSettings(), }) const [loginOpen, setLoginOpen] = useState(false) const [editingKey, setEditingKey] = useState(null) const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) // 登录态:只用来切换页头按钮与后续的编辑入口 useEffect(() => { let alive = true fetchSession().then((next) => { if (alive) setSession(next) }) fetchSettings().then((next) => { if (alive) setSettings(next) }) return () => { alive = false } }, []) const categories = useMemo( () => withLocalCategory(projects, catalog.categories), [projects, catalog.categories], ) const visibleCategories = useMemo( () => filterCategories(categories, keyword), [categories, keyword], ) // 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区; // 再按 /admin 里的顺序与显隐设置重排一遍 const sidebarCategories = useMemo( () => applySidebarSettings( categories.filter( (category) => category.id !== 'local' && category.showInSidebar !== false, ), settings.settings.sidebar, ), [categories, settings.settings.sidebar], ) 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), }) } /** * 侧栏导航:带分组时直接打开那个分组; * 点分区本身时,交给 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' }) /** 面包屑点击:把 href 解析回视图再跳转(保持 SPA 不刷新) */ const navigateHref = (href: string) => go(parseViewPath(href)) 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 ( setEditingKey(itemView.itemId)} /> ) } if (detailModel === undefined) { return } return ( openItem(itemCategory.id, itemId)} onEdit={() => setEditingKey(itemView.itemId)} /> ) } const handleLogout = () => { void logoutRequest().then(() => { setSession({ authenticated: false, has_admin: true }) }) } return ( <> {/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */} setLoginOpen(false)} onSuccess={(next) => { setSession(next) setLoginOpen(false) }} /> setEditingKey(null)} onSaved={() => catalog.reload()} /> {/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}