import { Breadcrumb, Empty, EngineIcon, Layout, Menu, Notice, SearchField, Space, Toaster, type EngineIconName, type MenuItem, } from '@stash/ui' import { useEffect, useMemo, useRef, useState } from 'react' import { BackToTop } from './components/common/BackToTop' import { ArticleDetailView } from './components/content/ArticleDetailView' import { AppGrid } from './components/content/AppGrid' import { CesCardGrid } from './components/content/CesCardGrid' import { ItemDetailView } from './components/content/ItemDetailView' import { ItemEditor } from './components/content/ItemEditor' import { PosterGrid } from './components/content/PosterGrid' import { SectionCard } from './components/content/SectionCard' import { SiteHeader } from './components/header/SiteHeader' import { ApiDocs } from './components/api/ApiDocs' import { GroupMark } from './components/common/GroupMark' import { LoginDialog } from './components/auth/LoginDialog' import { ProfileCard } from './components/sidebar/ProfileCard' import { ServicePanel } from './components/sidebar/ServicePanel' import { useProjects } from './useProjects' import { fetchSession, logout, type SessionState } from './auth' import { DEFAULT_SEARCH_ENGINES, fetchSettings, type SettingsDocument, type SiteSettings, } from './settings' import { isDarkScheme, readThemeOverride, writeThemeOverride, type ThemeChoice, } from './theme' import { appDetailModel, posterDetailModel, posterToRelatedItem, toRelatedItem, type ItemDetailModel, } from './data/detail' import type { AppItem, ArticleItem, PosterItem, RelatedItem } from './data/types' import { fetchManage, type CardItem, type GroupNode, type ManageData } from './manage' import './App.css' /** * 新前台:分组树 + 卡片组合。 * * 路由: * / 首页(卡片按勾选分组渲染) * /g/<分组 key> 分组页(自身条目 + 子分组) * /g/<分组 key>/i/<条目> 条目详情 * 旧 /c/... 地址按第一个路径段当成分组 key,兼容老书签。 */ type Route = | { kind: 'home' } | { kind: 'group'; key: string } | { kind: 'item'; key: string; itemId: string } | { kind: 'api' } const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '' } as const function parseRoute(pathname: string): Route { const segments = pathname.split('/').filter(Boolean) if (segments[0] === 'interface') return { kind: 'api' } if ( (segments[0] === 'g' || segments[0] === 'c') && segments[1] !== undefined ) { if (segments[2] === 'i' && segments[3] !== undefined) { return { kind: 'item', key: decodeURIComponent(segments[1]), itemId: decodeURIComponent(segments[3]) } } return { kind: 'group', key: decodeURIComponent(segments[1]) } } return { kind: 'home' } } function useRoute() { const [route, setRoute] = useState(() => parseRoute(window.location.pathname)) useEffect(() => { const sync = () => setRoute(parseRoute(window.location.pathname)) window.addEventListener('popstate', sync) return () => window.removeEventListener('popstate', sync) }, []) const navigate = (path: string) => { if (window.location.pathname === path) return window.history.pushState({}, '', path) setRoute(parseRoute(path)) document.getElementById('page-scroll')?.scrollTo({ top: 0 }) window.scrollTo({ top: 0 }) } return { route, navigate } } function findGroup(groups: GroupNode[], key: string): GroupNode | undefined { for (const group of groups) { if (group.key === key) return group const found = findGroup(group.children, key) if (found !== undefined) return found } return undefined } function findPath(groups: GroupNode[], key: string, trail: GroupNode[] = []): GroupNode[] | undefined { for (const group of groups) { const next = [...trail, group] if (group.key === key) return next const found = findPath(group.children, key, next) if (found !== undefined) return found } return undefined } function findEntryInGroup( group: GroupNode, entryId: string, ): { group: GroupNode; entry: Record } | undefined { const direct = group.entries.find((candidate) => candidate.id === entryId) if (direct !== undefined) { return { group, entry: direct as unknown as Record } } for (const child of group.children) { const found = findEntryInGroup(child, entryId) if (found !== undefined) return found } return undefined } function findEntry( groups: GroupNode[], groupKey: string, entryId: string, ): { group: GroupNode; entry: Record } | undefined { const group = findGroup(groups, groupKey) if (group === undefined) return undefined return findEntryInGroup(group, entryId) } function groupEntryCount(group: GroupNode): number { const children = group.children.filter((child) => child.enabled !== false) return group.entries.length + children.reduce((total, child) => total + groupEntryCount(child), 0) } /** 分组自身与所有祖先都启用时才可见:停用父级会连带停用整棵子树 */ function isGroupVisible(groups: GroupNode[], key: string): boolean { const path = findPath(groups, key) if (path === undefined) return false return path.every((node) => node.enabled !== false) } /** 分组子树里的全部条目(格式沿根分组继承,所以可以放在同一个网格里) */ function collectEntries(group: GroupNode): Record[] { return [ ...(group.entries as unknown as Record[]), ...group.children .filter((child) => child.enabled !== false) .flatMap((child) => collectEntries(child)), ] } function defaultLimit(group: GroupNode): number { return group.previewLimit ?? 6 } function entryFormat(entry: Record): 'apps' | 'posters' | 'articles' { const value = entry.format return value === 'posters' || value === 'articles' ? value : 'apps' } function matches(entry: Record, keyword: string): boolean { if (keyword === '') return true const text = [entry.name, entry.title, entry.description, entry.desc] .filter((value) => typeof value === 'string') .join(' ') .toLowerCase() return text.includes(keyword) } /** 本地搜索回车时打开第一个匹配项;没有条目命中但分组名命中时打开分组 */ function findLocalTarget( groups: GroupNode[], keyword: string, ): { group: GroupNode; entry?: Record } | undefined { for (const group of groups) { if (group.enabled === false) continue if (group.title.toLowerCase().includes(keyword)) return { group } const entry = (group.entries as unknown as Record[]).find((item) => matches(item, keyword), ) if (entry !== undefined) return { group, entry } const child = findLocalTarget(group.children, keyword) if (child !== undefined) return child } return undefined } export default function AppNext() { const { status, projects, error, live, refreshing, refresh } = useProjects() const { route, navigate } = useRoute() const [manage, setManage] = useState({ groups: [], cards: [] }) const [loading, setLoading] = useState(true) const [keyword, setKeyword] = useState('') const [session, setSession] = useState(null) const [settings, setSettings] = useState(null) const [loginOpen, setLoginOpen] = useState(false) const [editingKey, setEditingKey] = useState(null) const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) const [searchEngineId, setSearchEngineId] = useState(null) const [engineOpen, setEngineOpen] = useState(false) const engineRef = useRef(null) const load = () => { void fetchManage().then((data) => { setManage(data) setLoading(false) }) } useEffect(() => { load() void fetchSession().then(setSession) void fetchSettings().then(setSettings) const source = new EventSource('/api/catalog/events') // 服务端推的是具名事件 `catalog`(不是默认的 message),两种都接; // 收到后连站点设置一起重拉,改完设置不用手动刷新页面。 const onCatalogChange = () => { load() void fetchSettings().then(setSettings) } source.onmessage = onCatalogChange source.addEventListener('catalog', onCatalogChange) return () => source.close() }, []) useEffect(() => { const content = document.getElementById('content-scroll') const main = content?.parentElement const body = main?.parentElement if ( !(content instanceof HTMLElement) || !(main instanceof HTMLElement) || !(body instanceof HTMLElement) ) { return } // 左右两栏放进同一个滚动容器:滚动区域从搜索条下方开始,两栏位移完全一致。 // 左栏的吸顶交给 CSS position: sticky(App.css 里 #page-scroll > aside): // 之前用 scroll 事件改 transform,主线程永远落后平滑滚动一帧,看起来就在抖。 // 注意别再给 aside 设内联 overflow,否则会盖掉 CSS 里的 overflow-y: auto, // 侧栏比视口高时就没法在栏内滚动了。 body.id = 'page-scroll' body.style.overflowY = 'auto' body.style.overflowX = 'hidden' content.style.overflow = 'visible' content.style.marginTop = '0' main.style.height = 'auto' const onScroll = () => setScrolled(body.scrollTop > 4) // 滚动容器只覆盖 1380 的两栏,窗口两侧的空白、页头与搜索条都在它外面, // 滚轮落在这些地方时浏览器找不到可滚动元素。统一转发给内容区。 const forwardWheel = (event: WheelEvent) => { if (event.target instanceof Node && body.contains(event.target)) return // deltaMode 1 = 按行(Firefox 鼠标滚轮),换算成像素 body.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY event.preventDefault() } window.addEventListener('wheel', forwardWheel, { passive: false }) body.addEventListener('scroll', onScroll, { passive: true }) onScroll() return () => { body.removeEventListener('scroll', onScroll) window.removeEventListener('wheel', forwardWheel) body.id = '' body.style.overflowY = '' body.style.overflowX = '' content.style.overflow = '' content.style.marginTop = '' main.style.height = '' } }, []) const site: SiteSettings = settings?.settings ?? { header: { enabled: true, speed: 24, gap: 26, items: [] }, sidebar: { show_profile: true, show_service: true, show_nav: true, }, background: { light: '', dark: '' }, search: { default: 'internal', engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })), }, profile: { name: '本地管理员', role: '管理员', description: '', location: '', avatar: '', }, site: { title: 'Stash · 本地应用工作区', favicon: '', theme: 'auto', brand: { name: 'STASH', mark: 'S', logo: '' }, actions: { theme: true, refresh: true, settings: true, session: true }, }, } const keywordLower = keyword.trim().toLowerCase() const searchEngines = [ INTERNAL_SEARCH, ...site.search.engines .filter((engine) => engine.enabled) .map((engine) => ({ id: engine.id, name: engine.name, url: engine.url })), ] const searchEngine = searchEngines.find((engine) => engine.id === searchEngineId) ?? searchEngines.find((engine) => engine.id === site.search.default) ?? INTERNAL_SEARCH /** 只有站内搜索模式才用输入内容过滤本地内容 */ const localKeyword = searchEngine.id === 'internal' ? keywordLower : '' // 配色:本机偏好优先于站点默认,写在 上, // 深色由组件库的令牌块接管,页面与组件都不用改 const [themeOverride, setThemeOverride] = useState(() => readThemeOverride(), ) const effectiveTheme = themeOverride ?? site.site.theme /** 当前实际是深色吗(auto 时按系统偏好算) */ const isDark = isDarkScheme(effectiveTheme) const scheme = isDark ? 'dark' : 'light' useEffect(() => { const root = document.documentElement if (root.dataset.scheme === scheme) return const apply = () => { root.dataset.scheme = scheme } const doc = document as Document & { startViewTransition?: (callback: () => void) => unknown } const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches if (!reduceMotion && typeof doc.startViewTransition === 'function') { try { doc.startViewTransition(apply) return } catch { // 浏览器正在跑另一段过渡时退回 CSS 过渡 } } root.classList.add('theme-transition') apply() const timer = window.setTimeout(() => root.classList.remove('theme-transition'), 180) return () => { window.clearTimeout(timer) root.classList.remove('theme-transition') } }, [scheme]) /** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */ const toggleTheme = () => { const next: ThemeChoice = isDark ? 'light' : 'dark' setThemeOverride(next) writeThemeOverride(next) } useEffect(() => { if (!engineOpen) return const close = (event: PointerEvent) => { if (engineRef.current !== null && !engineRef.current.contains(event.target as Node)) { setEngineOpen(false) } } document.addEventListener('pointerdown', close) return () => document.removeEventListener('pointerdown', close) }, [engineOpen]) // 回车用当前搜索引擎打开新标签页;输入时的本地过滤逻辑保持不变 useEffect(() => { const input = searchRef.current if (input === null) return const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setEngineOpen(false) return } if (event.key !== 'Enter' || keyword.trim() === '') return event.preventDefault() setEngineOpen(false) if (searchEngine.id === 'internal') { const target = findLocalTarget(manage.groups, keywordLower) if (target !== undefined) { navigate( target.entry === undefined ? `/g/${encodeURIComponent(target.group.key)}` : `/g/${encodeURIComponent(target.group.key)}/i/${encodeURIComponent(String(target.entry.id))}`, ) } return } const searchUrl = searchEngine.url.replaceAll( '{query}', encodeURIComponent(keyword.trim()), ) window.open(searchUrl, '_blank', 'noopener,noreferrer') } input.addEventListener('keydown', handleKeyDown) return () => input.removeEventListener('keydown', handleKeyDown) }, [keyword, keywordLower, manage.groups, navigate, searchEngine]) const chooseEngine = (next: string) => { setSearchEngineId(next) setEngineOpen(false) searchRef.current?.focus() } // 站点级设置:浏览器标签标题与 favicon(空值回落到内置) useEffect(() => { const title = site.site.title.trim() if (title !== '') document.title = title const href = site.site.favicon.trim() === '' ? '/favicon.svg' : site.site.favicon.trim() let link = document.head.querySelector('link[rel="icon"]') if (link === null) { link = document.createElement('link') link.rel = 'icon' document.head.appendChild(link) } link.href = href link.type = href.endsWith('.svg') ? 'image/svg+xml' : '' }, [site.site.title, site.site.favicon]) const sidebarItems: MenuItem[] = useMemo(() => { // 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航, // 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。 const build = (nodes: GroupNode[]): MenuItem[] => [...nodes] .sort((a, b) => a.sort - b.sort) .flatMap((node) => { if (node.enabled === false) return [] const children = build(node.children) return [ { key: `g:${node.key}`, label: node.title, children: children.length > 0 ? children : undefined, }, ] }) return [{ key: 'home', label: '首页' }, ...build(manage.groups)] }, [manage.groups]) const activeKey = route.kind === 'home' || route.kind === 'api' ? 'home' : route.kind === 'group' ? `g:${route.key}` : `g:${route.key}` function renderEntries( group: GroupNode, entries: Record[], options: { detail?: boolean; limit?: number } = {}, ) { const filtered = entries.filter((entry) => matches(entry, localKeyword)) const limit = options.limit ?? filtered.length const items = filtered.slice(0, limit) const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`) return ( <> {(['apps', 'posters', 'articles'] as const).map((format) => { const subset = items.filter((entry) => entryFormat(entry) === format) if (subset.length === 0) return null return (
{format === 'posters' ? ( ) : format === 'articles' ? ( ) : ( )}
) })} ) } function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) { // 根分组(分区)只展示直接挂在它下面的条目,子分组各自成块,避免同一批内容出现两次 const rootGroup = group.parentKey === undefined || group.parentKey === null const source = rootGroup ? (group.entries as unknown as Record[]) : collectEntries(group) const all = source.filter((entry) => matches(entry, localKeyword)) if (all.length === 0) return null return (
{showTitle && !rootGroup &&

{group.title}

} {renderEntries(group, all, { limit })}
) } function renderLocalService(card: CardItem) { const items: AppItem[] = projects.map((project) => ({ id: project.name, name: project.name, desc: project.route_path, icon: project.name.slice(0, 1).toUpperCase(), href: project.route_path, badge: '本地项目', comment: '本地静态站点,由 Stash 直接托管。', views: 0, likes: 0, })) return ( {items.length > 0 ? ( ) : ( )} ) } function renderCard(card: CardItem) { const resolved = card.groups .map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) })) .filter( (item): item is { ref: typeof item.ref; group: GroupNode } => item.group !== undefined && isGroupVisible(manage.groups, item.ref.groupKey), ) const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0) const first = resolved[0]?.group if (resolved.length === 0 || total === 0) return null return ( {resolved.map(({ group, ref }) => renderGroupBlock(group, ref.previewLimit ?? defaultLimit(group), resolved.length > 1), )} ) } function renderHome() { const cards = manage.cards return (
{loading && } {!loading && cards.length === 0 && } {cards.map((card) => card.key === 'local-service' ? renderLocalService(card) : renderCard(card), )} {localKeyword !== '' && cards.length > 0 && ( 在卡片与分组内按标题过滤。 )}
) } function renderGroupPage(key: string) { const group = findGroup(manage.groups, key) if (group === undefined || !isGroupVisible(manage.groups, key)) { return } const trail = findPath(manage.groups, key) ?? [group] // 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」 const rootGroup = group.parentKey === undefined || group.parentKey === null return (
({ label: node.title, href: `/g/${encodeURIComponent(node.key)}`, })), ]} onNavigate={navigate} />

{group.title}

[{groupEntryCount(group)}]
{group.entries.length > 0 ? ( renderEntries(group, group.entries) ) : group.children.some((child) => groupEntryCount(child) > 0) ? null : ( )} {group.children .filter((child) => child.enabled !== false) .map((child) => (
{child.entries.length > 0 ? ( renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 }) ) : ( )}
))}
) } function renderItemPage(key: string, itemId: string) { const found = findEntry(manage.groups, key, itemId) if (found === undefined || !isGroupVisible(manage.groups, found.group.key)) { return } const { group, entry } = found const trail = findPath(manage.groups, group.key) ?? [group] const crumbs = [ { label: '首页', href: '/' }, ...trail.map((node) => ({ label: node.title, href: `/g/${encodeURIComponent(node.key)}`, })), { label: String(entry.name ?? entry.title ?? entry.id) }, ] const format = entryFormat(entry) if (format === 'articles') { const article = entry as unknown as ArticleItem return ( setEditingKey(String(entry.id))} canEdit={session?.authenticated === true} /> ) } const model: ItemDetailModel = format === 'posters' ? posterDetailModel(entry as unknown as PosterItem) : appDetailModel(entry as unknown as AppItem) const related: RelatedItem[] = format === 'posters' ? group.entries .filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record) === format) .slice(0, 4) .map((item) => posterToRelatedItem(item as unknown as PosterItem)) : group.entries .filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record) === format) .slice(0, 4) .map((item) => toRelatedItem(item as unknown as AppItem)) return ( navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(next)}`)} onEdit={() => setEditingKey(String(entry.id))} canEdit={session?.authenticated === true} /> ) } return ( <> setLoginOpen(false)} onSuccess={(next) => { setSession(next) setLoginOpen(false) }} /> setEditingKey(null)} onSaved={() => { setEditingKey(null) load() }} />