对齐项(8080 vs 8081 实测,误差 ≤1px): - 侧栏:资料卡 224、服务面板 144、导航 143,navTop 548 与原版一致 - 分区卡:142/142/327/270 vs 142/142/326/269;标题 16/1.2、更多按钮 24px - 应用条目:48px 圆形头像 + 10px 小认证标;hover 名字变主色(不再整行变色) - 封面:hover 上浮 2px(0.2s ease),去掉缩放;CES 封面恢复 16/10 - 搜索框:44 高、无边框 + 卡片阴影 - 服务面板:状态药丸 22、统计 12/18、操作按钮 28、刷新时图标原地旋转 - 详情:返回链接 hover 下划线、跳转按钮 hover 深主色、view-in 淡入动画 - 菜单:hover 主色、子菜单 0.28s grid 高度动画、chevron 6px 旋转 - 回到顶部:right 30、top 50%、滚动后淡入(补内容区滚动监听) - 主题:卡片阴影恢复 2%/6%(旧版值)
354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
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<HTMLInputElement>(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 <Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
|
||
}
|
||
|
||
if (itemCategory.kind === 'articles') {
|
||
const article = findArticleItem(itemCategory, itemView.itemId)
|
||
if (article === undefined) {
|
||
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
|
||
}
|
||
|
||
return (
|
||
<ArticleDetailView
|
||
article={article}
|
||
categoryTitle={itemCategory.title}
|
||
onBack={() => back(detailTargetFor(itemView))}
|
||
/>
|
||
)
|
||
}
|
||
|
||
if (detailModel === undefined) {
|
||
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
|
||
}
|
||
|
||
return (
|
||
<ItemDetailView
|
||
breadcrumb={`${itemCategory.title} / ${detailModel.title}`}
|
||
model={detailModel}
|
||
related={relatedItems}
|
||
onBack={() => back(detailTargetFor(itemView))}
|
||
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
||
<div className="site-background" aria-hidden="true" />
|
||
|
||
<Layout
|
||
contained
|
||
maxWidth={1380}
|
||
gutter={20}
|
||
siderWidth={240}
|
||
gap={20}
|
||
headerHeight={60}
|
||
headerVariant="solid"
|
||
siderBordered={false}
|
||
contentPadding="none"
|
||
contentOffset={88}
|
||
height="screen"
|
||
scrollable
|
||
contentId="content-scroll"
|
||
className="app"
|
||
header={
|
||
<SiteHeader
|
||
projectCount={projects.length}
|
||
sampleProject={projects[0]?.name}
|
||
live={live}
|
||
onFocusSearch={() => searchRef.current?.focus()}
|
||
/>
|
||
}
|
||
sider={
|
||
<Sidebar
|
||
projects={projects}
|
||
categories={sidebarCategories}
|
||
live={live}
|
||
refreshing={refreshing}
|
||
onRefresh={refresh}
|
||
activeCategoryId={viewCategoryId}
|
||
activeGroupId={detailView?.groupId}
|
||
onNavigate={navigate}
|
||
/>
|
||
}
|
||
>
|
||
<div className="content">
|
||
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
||
|
||
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
||
|
||
{status === 'error' && (
|
||
<Notice
|
||
tone="error"
|
||
title="连不上 Stash 服务"
|
||
action={{ label: '重试', onClick: refresh }}
|
||
>
|
||
{error}
|
||
</Notice>
|
||
)}
|
||
|
||
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
|
||
{catalog.status === 'error' && (
|
||
<Notice
|
||
tone="error"
|
||
title="内容库读取失败"
|
||
action={{ label: '重试', onClick: catalog.reload }}
|
||
>
|
||
{catalog.error}
|
||
</Notice>
|
||
)}
|
||
|
||
{detailView === undefined && itemView === undefined && (
|
||
<>
|
||
{catalog.status === 'loading' && <Empty text="正在读取内容库…" />}
|
||
|
||
<HomeView
|
||
categories={visibleCategories}
|
||
onOpenDetail={openDetail}
|
||
onOpenItem={openItem}
|
||
/>
|
||
|
||
{searching && visibleCategories.length === 0 && (
|
||
<Notice title={`没有匹配「${keyword.trim()}」的内容`}>
|
||
换个关键字试试,或清空搜索查看全部。
|
||
</Notice>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{detailView !== undefined && detailCategory !== undefined && (
|
||
<DetailView
|
||
category={detailCategory}
|
||
groupId={detailView.groupId}
|
||
onBack={goHome}
|
||
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
|
||
/>
|
||
)}
|
||
|
||
{detailView !== undefined && detailCategory === undefined && (
|
||
<Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
|
||
)}
|
||
|
||
{itemView !== undefined && renderItemView()}
|
||
</div>
|
||
</Layout>
|
||
|
||
<BackToTop visible={scrolled} />
|
||
</>
|
||
)
|
||
}
|