Files
stash/stash-web/src/App.tsx
T
stash 1942e201e9 fix(web): 视觉与交互 1:1(页头/侧栏/卡片/详情/动效)
对齐项(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%(旧版值)
2026-10-28 20:16:41 +08:00

354 lines
12 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.
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} />
</>
)
}