BrowsePage.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import { useCallback, useEffect, useState, type FormEvent } from 'react'
  2. import { discoverMovies, searchMovies } from '../api/movies'
  3. import { CatalogDetail } from '../components/CatalogDetail'
  4. import { CatalogTile } from '../components/CatalogTile'
  5. import { SiteFooter } from '../components/SiteFooter'
  6. import { SiteNav } from '../components/SiteNav'
  7. import { loadSeen, type SeenEntry } from '../lib/seen'
  8. import type { CandidateMovie } from '../types'
  9. import { GENRE_OPTIONS } from '../types'
  10. type Mode = 'search' | 'discover'
  11. const SORT_OPTIONS = [
  12. { value: 'popularity.desc', label: '热度' },
  13. { value: 'vote_average.desc', label: '评分' },
  14. { value: 'primary_release_date.desc', label: '新片优先' },
  15. ] as const
  16. const LANG_OPTIONS = [
  17. { value: '', label: '语言不限' },
  18. { value: 'zh', label: '华语' },
  19. { value: 'en', label: '英语' },
  20. { value: 'ja', label: '日语' },
  21. { value: 'ko', label: '韩语' },
  22. ] as const
  23. export function BrowsePage() {
  24. const [mode, setMode] = useState<Mode>('search')
  25. const [query, setQuery] = useState('')
  26. const [year, setYear] = useState('')
  27. const [genres, setGenres] = useState<string[]>([])
  28. const [lang, setLang] = useState('')
  29. const [sortBy, setSortBy] = useState<string>('popularity.desc')
  30. const [yearGte, setYearGte] = useState('')
  31. const [maxRuntime, setMaxRuntime] = useState('')
  32. const [loading, setLoading] = useState(false)
  33. const [error, setError] = useState<string | null>(null)
  34. const [results, setResults] = useState<CandidateMovie[] | null>(null)
  35. const [selected, setSelected] = useState<CandidateMovie | null>(null)
  36. const [seenList, setSeenList] = useState<SeenEntry[]>(() => loadSeen())
  37. const runDiscover = useCallback(async () => {
  38. setLoading(true)
  39. setError(null)
  40. try {
  41. const res = await discoverMovies({
  42. with_genres: genres.length ? genres.join(',') : undefined,
  43. year_gte: yearGte ? Number(yearGte) : undefined,
  44. max_runtime: maxRuntime ? Number(maxRuntime) : undefined,
  45. with_original_language: lang || undefined,
  46. sort_by: sortBy,
  47. page: 1,
  48. })
  49. setResults(res.data)
  50. setSelected(null)
  51. } catch (err) {
  52. setResults(null)
  53. setError(err instanceof Error ? err.message : '发现失败')
  54. } finally {
  55. setLoading(false)
  56. }
  57. }, [genres, yearGte, maxRuntime, lang, sortBy])
  58. useEffect(() => {
  59. if (mode !== 'discover') return
  60. void runDiscover()
  61. }, [mode, runDiscover])
  62. async function onSearchSubmit(e: FormEvent) {
  63. e.preventDefault()
  64. const q = query.trim()
  65. if (!q) return
  66. setLoading(true)
  67. setError(null)
  68. try {
  69. const res = await searchMovies(q, year ? Number(year) : undefined)
  70. setResults(res.data)
  71. setSelected(null)
  72. } catch (err) {
  73. setResults(null)
  74. setError(err instanceof Error ? err.message : '搜索失败')
  75. } finally {
  76. setLoading(false)
  77. }
  78. }
  79. function toggleGenre(g: string) {
  80. setGenres((prev) =>
  81. prev.includes(g) ? prev.filter((x) => x !== g) : [...prev, g].slice(0, 3),
  82. )
  83. }
  84. const seenIds = new Set(seenList.map((e) => e.id))
  85. return (
  86. <div className="page page--browse">
  87. <SiteNav active="browse" />
  88. <header className="browse-hero">
  89. <p className="section-kicker">片库</p>
  90. <h1 className="browse-title">找一部确认今晚</h1>
  91. <p className="browse-lead">
  92. 搜片名,或按类型与年份筛选。点海报看详情,可标记已看。
  93. </p>
  94. </header>
  95. <div className="mode-tabs" role="tablist" aria-label="查询方式">
  96. <button
  97. type="button"
  98. role="tab"
  99. aria-selected={mode === 'search'}
  100. className={mode === 'search' ? 'mode-tab is-active' : 'mode-tab'}
  101. onClick={() => {
  102. setMode('search')
  103. setResults(null)
  104. setSelected(null)
  105. setError(null)
  106. }}
  107. >
  108. 关键词搜索
  109. </button>
  110. <button
  111. type="button"
  112. role="tab"
  113. aria-selected={mode === 'discover'}
  114. className={mode === 'discover' ? 'mode-tab is-active' : 'mode-tab'}
  115. onClick={() => setMode('discover')}
  116. >
  117. 条件发现
  118. </button>
  119. </div>
  120. {mode === 'search' ? (
  121. <form className="browse-toolbar" onSubmit={(e) => void onSearchSubmit(e)}>
  122. <input
  123. type="search"
  124. value={query}
  125. onChange={(e) => setQuery(e.target.value)}
  126. placeholder="片名、关键词,例如:盗梦空间"
  127. autoComplete="off"
  128. aria-label="搜索关键词"
  129. />
  130. <input
  131. type="number"
  132. className="browse-year"
  133. value={year}
  134. onChange={(e) => setYear(e.target.value)}
  135. placeholder="年份"
  136. min={1900}
  137. max={2100}
  138. aria-label="上映年份"
  139. />
  140. <button
  141. type="submit"
  142. className="btn btn--primary"
  143. disabled={loading || !query.trim()}
  144. >
  145. {loading ? '检索中…' : '搜索'}
  146. </button>
  147. </form>
  148. ) : (
  149. <div className="browse-filters">
  150. <div className="chip-row" aria-label="类型,最多三项">
  151. {GENRE_OPTIONS.map((g) => (
  152. <button
  153. key={g}
  154. type="button"
  155. className={genres.includes(g) ? 'chip is-on' : 'chip'}
  156. onClick={() => toggleGenre(g)}
  157. >
  158. {g}
  159. </button>
  160. ))}
  161. </div>
  162. <div className="browse-filters__row">
  163. <label>
  164. <span className="sr-only">起始年份</span>
  165. <input
  166. type="number"
  167. value={yearGte}
  168. onChange={(e) => setYearGte(e.target.value)}
  169. placeholder="起始年"
  170. min={1900}
  171. max={2100}
  172. />
  173. </label>
  174. <label>
  175. <span className="sr-only">片长上限</span>
  176. <select
  177. value={maxRuntime}
  178. onChange={(e) => setMaxRuntime(e.target.value)}
  179. >
  180. <option value="">片长不限</option>
  181. <option value="90">≤ 90 分</option>
  182. <option value="120">≤ 120 分</option>
  183. <option value="150">≤ 150 分</option>
  184. </select>
  185. </label>
  186. <label>
  187. <span className="sr-only">语言</span>
  188. <select value={lang} onChange={(e) => setLang(e.target.value)}>
  189. {LANG_OPTIONS.map((o) => (
  190. <option key={o.value || 'any'} value={o.value}>
  191. {o.label}
  192. </option>
  193. ))}
  194. </select>
  195. </label>
  196. <label>
  197. <span className="sr-only">排序</span>
  198. <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
  199. {SORT_OPTIONS.map((o) => (
  200. <option key={o.value} value={o.value}>
  201. {o.label}
  202. </option>
  203. ))}
  204. </select>
  205. </label>
  206. <button
  207. type="button"
  208. className="btn btn--primary"
  209. onClick={() => void runDiscover()}
  210. disabled={loading}
  211. >
  212. {loading ? '刷新中…' : '刷新结果'}
  213. </button>
  214. </div>
  215. </div>
  216. )}
  217. {error && (
  218. <p className="error-banner" role="alert">
  219. {error}
  220. </p>
  221. )}
  222. <div className={selected ? 'browse-split has-detail' : 'browse-split'}>
  223. <section className="catalog-grid" aria-label="影片列表">
  224. {loading && !results && <p className="muted">正在连接片库…</p>}
  225. {results && results.length === 0 && (
  226. <p className="muted">没有符合条件的影片,换个关键词或放宽筛选试试。</p>
  227. )}
  228. {results &&
  229. results.map((m, i) => (
  230. <CatalogTile
  231. key={m.id}
  232. movie={m}
  233. index={i}
  234. selected={selected?.id === m.id}
  235. seen={seenIds.has(m.id)}
  236. onSelect={setSelected}
  237. />
  238. ))}
  239. {!loading && results == null && mode === 'search' && (
  240. <p className="browse-empty muted">
  241. 输入片名确认年份与海报,或切换到「条件发现」按口味逛一圈。
  242. </p>
  243. )}
  244. </section>
  245. {selected && (
  246. <CatalogDetail
  247. movie={selected}
  248. onClose={() => setSelected(null)}
  249. onSeenChange={setSeenList}
  250. />
  251. )}
  252. </div>
  253. {seenList.length > 0 && (
  254. <p className="browse-seen-hint muted">
  255. 已标记 {seenList.length} 部已看;返回智能推荐时会自动带入排除列表。
  256. </p>
  257. )}
  258. <SiteFooter />
  259. </div>
  260. )
  261. }