947 lines
33 KiB
TypeScript
947 lines
33 KiB
TypeScript
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<Route>(() => 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<string, unknown> } | undefined {
|
||
const direct = group.entries.find((candidate) => candidate.id === entryId)
|
||
if (direct !== undefined) {
|
||
return { group, entry: direct as unknown as Record<string, unknown> }
|
||
}
|
||
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<string, unknown> } | 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<string, unknown>[] {
|
||
return [
|
||
...(group.entries as unknown as Record<string, unknown>[]),
|
||
...group.children
|
||
.filter((child) => child.enabled !== false)
|
||
.flatMap((child) => collectEntries(child)),
|
||
]
|
||
}
|
||
|
||
function defaultLimit(group: GroupNode): number {
|
||
return group.previewLimit ?? 6
|
||
}
|
||
|
||
function entryFormat(entry: Record<string, unknown>): 'apps' | 'posters' | 'articles' {
|
||
const value = entry.format
|
||
return value === 'posters' || value === 'articles' ? value : 'apps'
|
||
}
|
||
|
||
function matches(entry: Record<string, unknown>, 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<string, unknown> } | 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<string, unknown>[]).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<ManageData>({ groups: [], cards: [] })
|
||
const [loading, setLoading] = useState(true)
|
||
const [keyword, setKeyword] = useState('')
|
||
const [session, setSession] = useState<SessionState | null>(null)
|
||
const [settings, setSettings] = useState<SettingsDocument | null>(null)
|
||
const [loginOpen, setLoginOpen] = useState(false)
|
||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||
const [scrolled, setScrolled] = useState(false)
|
||
const searchRef = useRef<HTMLInputElement>(null)
|
||
const [searchEngineId, setSearchEngineId] = useState<string | null>(null)
|
||
const [engineOpen, setEngineOpen] = useState(false)
|
||
const engineRef = useRef<HTMLDivElement>(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 : ''
|
||
|
||
// 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上,
|
||
// 深色由组件库的令牌块接管,页面与组件都不用改
|
||
const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() =>
|
||
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<HTMLLinkElement>('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<string, unknown>[],
|
||
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 (
|
||
<div className="mixed-format-block" key={format}>
|
||
{format === 'posters' ? (
|
||
<PosterGrid items={subset as unknown as PosterItem[]} onOpen={open} />
|
||
) : format === 'articles' ? (
|
||
<CesCardGrid items={subset as unknown as ArticleItem[]} onOpen={open} />
|
||
) : (
|
||
<AppGrid items={subset as unknown as AppItem[]} onOpen={open} />
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</>
|
||
)
|
||
}
|
||
|
||
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<string, unknown>[])
|
||
: collectEntries(group)
|
||
const all = source.filter((entry) => matches(entry, localKeyword))
|
||
if (all.length === 0) return null
|
||
return (
|
||
<div key={group.key} className="card-group-block">
|
||
{showTitle && !rootGroup && <h3 className="card-group-title">{group.title}</h3>}
|
||
{renderEntries(group, all, { limit })}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<SectionCard key={card.id} title="本地服务" count={items.length} barColor="#3858f6">
|
||
{items.length > 0 ? (
|
||
<AppGrid items={items} />
|
||
) : (
|
||
<Empty text="还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。" />
|
||
)}
|
||
</SectionCard>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<SectionCard
|
||
key={card.id}
|
||
title={card.title}
|
||
count={total}
|
||
barColor={first?.barColor}
|
||
>
|
||
{resolved.map(({ group, ref }) =>
|
||
renderGroupBlock(group, ref.previewLimit ?? defaultLimit(group), resolved.length > 1),
|
||
)}
|
||
</SectionCard>
|
||
)
|
||
}
|
||
|
||
function renderHome() {
|
||
const cards = manage.cards
|
||
return (
|
||
<div className="home-view">
|
||
{loading && <Empty text="正在读取内容库…" />}
|
||
{!loading && cards.length === 0 && <Empty text="后台还没有创建首页卡片" />}
|
||
{cards.map((card) =>
|
||
card.key === 'local-service' ? renderLocalService(card) : renderCard(card),
|
||
)}
|
||
{localKeyword !== '' && cards.length > 0 && (
|
||
<Notice title={`搜索结果:${keyword.trim()}`}>在卡片与分组内按标题过滤。</Notice>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function renderGroupPage(key: string) {
|
||
const group = findGroup(manage.groups, key)
|
||
if (group === undefined || !isGroupVisible(manage.groups, key)) {
|
||
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
|
||
}
|
||
|
||
const trail = findPath(manage.groups, key) ?? [group]
|
||
// 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」
|
||
const rootGroup = group.parentKey === undefined || group.parentKey === null
|
||
|
||
return (
|
||
<div className="detail-view">
|
||
<Breadcrumb
|
||
className="detail-breadcrumb"
|
||
items={[
|
||
{ label: '首页', href: '/' },
|
||
...trail.map((node) => ({
|
||
label: node.title,
|
||
href: `/g/${encodeURIComponent(node.key)}`,
|
||
})),
|
||
]}
|
||
onNavigate={navigate}
|
||
/>
|
||
|
||
<div className="detail-hero">
|
||
<span className="detail-hero-icon" aria-hidden="true">
|
||
<GroupMark icon={group.icon} fallback="•" />
|
||
</span>
|
||
<h2 className="detail-hero-title">{group.title}</h2>
|
||
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
|
||
</div>
|
||
|
||
{group.entries.length > 0 ? (
|
||
renderEntries(group, group.entries)
|
||
) : group.children.some((child) => groupEntryCount(child) > 0) ? null : (
|
||
<Empty text="这个分组还没有内容" />
|
||
)}
|
||
|
||
<Space direction="column" gap="lg" align="stretch">
|
||
{group.children
|
||
.filter((child) => child.enabled !== false)
|
||
.map((child) => (
|
||
<section key={child.key} className="group-child-block">
|
||
<div className="group-title">
|
||
<span
|
||
className="group-bar"
|
||
style={{ backgroundColor: child.barColor || undefined }}
|
||
aria-hidden="true"
|
||
/>
|
||
<h3>{child.title}</h3>
|
||
{!rootGroup && (
|
||
<button
|
||
type="button"
|
||
className="link-button"
|
||
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
|
||
>
|
||
查看全部
|
||
</button>
|
||
)}
|
||
</div>
|
||
{child.entries.length > 0 ? (
|
||
renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 })
|
||
) : (
|
||
<Empty text="暂无内容" />
|
||
)}
|
||
</section>
|
||
))}
|
||
</Space>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function renderItemPage(key: string, itemId: string) {
|
||
const found = findEntry(manage.groups, key, itemId)
|
||
if (found === undefined || !isGroupVisible(manage.groups, found.group.key)) {
|
||
return <Empty text={loading ? '正在读取内容库…' : '条目不存在'} />
|
||
}
|
||
|
||
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 (
|
||
<ArticleDetailView
|
||
article={article}
|
||
categoryId={group.key}
|
||
categoryTitle={group.title}
|
||
onNavigate={navigate}
|
||
onEdit={() => 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<string, unknown>) === 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<string, unknown>) === format)
|
||
.slice(0, 4)
|
||
.map((item) => toRelatedItem(item as unknown as AppItem))
|
||
|
||
return (
|
||
<ItemDetailView
|
||
itemKey={String(entry.id)}
|
||
crumbs={crumbs}
|
||
model={model}
|
||
related={related}
|
||
onNavigate={navigate}
|
||
onOpenItem={(next) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(next)}`)}
|
||
onEdit={() => setEditingKey(String(entry.id))}
|
||
canEdit={session?.authenticated === true}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Toaster />
|
||
<LoginDialog
|
||
open={loginOpen}
|
||
hasAdmin={session?.has_admin ?? true}
|
||
onClose={() => setLoginOpen(false)}
|
||
onSuccess={(next) => {
|
||
setSession(next)
|
||
setLoginOpen(false)
|
||
}}
|
||
/>
|
||
|
||
<ItemEditor
|
||
open={editingKey !== null}
|
||
itemKey={editingKey}
|
||
onClose={() => setEditingKey(null)}
|
||
onSaved={() => {
|
||
setEditingKey(null)
|
||
load()
|
||
}}
|
||
/>
|
||
|
||
<div
|
||
className="site-background site-background-light"
|
||
style={
|
||
site.background.light.trim() === ''
|
||
? undefined
|
||
: { backgroundImage: `url(${JSON.stringify(site.background.light.trim())})` }
|
||
}
|
||
aria-hidden="true"
|
||
/>
|
||
<div
|
||
className="site-background site-background-dark"
|
||
style={
|
||
site.background.dark.trim() === ''
|
||
? undefined
|
||
: { backgroundImage: `url(${JSON.stringify(site.background.dark.trim())})` }
|
||
}
|
||
aria-hidden="true"
|
||
/>
|
||
|
||
<Layout
|
||
contained
|
||
maxWidth={1380}
|
||
gutter={20}
|
||
siderWidth={240}
|
||
gap={20}
|
||
headerHeight={60}
|
||
headerVariant="solid"
|
||
siderBordered={false}
|
||
contentPadding="none"
|
||
height="screen"
|
||
scrollable
|
||
hideScrollbar
|
||
contentId="content-scroll"
|
||
className="app"
|
||
header={
|
||
<SiteHeader
|
||
projectCount={projects.length}
|
||
sampleProject={projects[0]?.name}
|
||
live={live}
|
||
headerItems={site.header.items}
|
||
headerEnabled={site.header.enabled}
|
||
headerSpeed={site.header.speed}
|
||
headerGap={site.header.gap}
|
||
site={site.site}
|
||
ready={settings !== null}
|
||
authenticated={session?.authenticated === true}
|
||
username={session?.username ?? undefined}
|
||
refreshing={refreshing}
|
||
dark={isDark}
|
||
onToggleTheme={() => void toggleTheme()}
|
||
onRefresh={() => {
|
||
// 刷新 = 整页重载:项目扫描、内容库、设置、主题都重新走一遍
|
||
window.location.reload()
|
||
}}
|
||
onLogin={() => setLoginOpen(true)}
|
||
onLogout={() => {
|
||
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
|
||
}}
|
||
onSettings={() => window.location.assign('/admin')}
|
||
/>
|
||
}
|
||
toolbar={
|
||
<div className="search-toolbar">
|
||
<div className="search-shell" ref={engineRef}>
|
||
<button
|
||
type="button"
|
||
className="engine-badge"
|
||
aria-haspopup="menu"
|
||
aria-expanded={engineOpen}
|
||
aria-label={`搜索引擎:${searchEngine.name},点击切换`}
|
||
title={
|
||
searchEngine.id === 'internal'
|
||
? '当前:站内搜索;回车打开第一个匹配项'
|
||
: `当前:${searchEngine.name};回车用该引擎搜索`
|
||
}
|
||
onClick={() => setEngineOpen((open) => !open)}
|
||
>
|
||
<EngineIcon name={searchEngine.id as EngineIconName} size={14} />
|
||
<span>{searchEngine.name}</span>
|
||
</button>
|
||
|
||
<SearchField
|
||
value={keyword}
|
||
onChange={setKeyword}
|
||
inputRef={searchRef}
|
||
maxWidth="100%"
|
||
placeholder={
|
||
searchEngine.id === 'internal'
|
||
? '搜索站内项目…'
|
||
: `使用 ${searchEngine.name} 搜索...`
|
||
}
|
||
label={
|
||
searchEngine.id === 'internal'
|
||
? '搜索站内项目,回车打开第一个匹配项'
|
||
: `使用 ${searchEngine.name} 搜索,回车打开新标签页`
|
||
}
|
||
variant="card"
|
||
hotkey="k"
|
||
/>
|
||
|
||
{engineOpen && (
|
||
<div className="engine-dropdown" role="menu" aria-label="选择搜索引擎">
|
||
<button
|
||
type="button"
|
||
role="menuitemradio"
|
||
aria-checked={searchEngine.id === 'internal'}
|
||
className={`engine-option${searchEngine.id === 'internal' ? ' is-active' : ''}`}
|
||
onClick={() => chooseEngine('internal')}
|
||
>
|
||
<EngineIcon name="internal" size={18} />
|
||
<span>站内搜索</span>
|
||
</button>
|
||
{searchEngines.length > 1 && (
|
||
<>
|
||
<div className="engine-divider" />
|
||
{searchEngines.slice(1).map((engine) => (
|
||
<button
|
||
key={engine.id}
|
||
type="button"
|
||
role="menuitemradio"
|
||
aria-checked={engine.id === searchEngine.id}
|
||
className={`engine-option${engine.id === searchEngine.id ? ' is-active' : ''}`}
|
||
onClick={() => chooseEngine(engine.id)}
|
||
>
|
||
<EngineIcon name={engine.id as EngineIconName} size={18} />
|
||
<span>{engine.name}</span>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
}
|
||
sider={
|
||
<div className="sidebar-wrap">
|
||
<div className="sidebar sidebar-home">
|
||
{settings !== null && site.sidebar.show_profile !== false && (
|
||
<ProfileCard profile={site.profile} />
|
||
)}
|
||
{settings !== null && site.sidebar.show_service !== false && (
|
||
<ServicePanel
|
||
projects={projects}
|
||
live={live}
|
||
refreshing={refreshing}
|
||
onRefresh={refresh}
|
||
onOpenApi={() => navigate('/interface')}
|
||
/>
|
||
)}
|
||
{settings !== null && !loading && site.sidebar.show_nav !== false && (
|
||
<div className="nav-card">
|
||
<Menu
|
||
items={sidebarItems}
|
||
activeKey={activeKey}
|
||
itemHeight={39}
|
||
onSelect={(key) => {
|
||
if (key === 'home') navigate('/')
|
||
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="content">
|
||
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
||
|
||
{status === 'error' && (
|
||
<Notice
|
||
tone="error"
|
||
title="连不上 Stash 服务"
|
||
action={{ label: '重试', onClick: refresh }}
|
||
>
|
||
{error}
|
||
</Notice>
|
||
)}
|
||
|
||
{route.kind === 'home' && renderHome()}
|
||
{route.kind === 'api' && <ApiDocs />}
|
||
{route.kind === 'group' && renderGroupPage(route.key)}
|
||
{route.kind === 'item' && renderItemPage(route.key, route.itemId)}
|
||
</div>
|
||
</Layout>
|
||
|
||
<BackToTop visible={scrolled} targetId="page-scroll" />
|
||
</>
|
||
)
|
||
}
|