ぼざクリタグ広場 https://hub.nizika.monster
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

290 lines
9.3 KiB

  1. import { useQuery } from '@tanstack/react-query'
  2. import { AnimatePresence, motion } from 'framer-motion'
  3. import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
  4. import { useLocation } from 'react-router-dom'
  5. import Separator from '@/components/MenuSeparator'
  6. import PrefetchLink from '@/components/PrefetchLink'
  7. import TopNavUser from '@/components/TopNavUser'
  8. import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
  9. import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
  10. import { fetchTagByName } from '@/lib/tags'
  11. import { cn } from '@/lib/utils'
  12. import { fetchWikiPage } from '@/lib/wiki'
  13. import type { FC, MouseEvent } from 'react'
  14. import type { Menu, User } from '@/types'
  15. type Props = { user: User | null }
  16. export default (({ user }: Props) => {
  17. const location = useLocation ()
  18. const dirRef = useRef<(-1) | 1> (1)
  19. const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
  20. const navRef = useRef<HTMLDivElement | null> (null)
  21. const measure = () => {
  22. const nav = navRef.current
  23. const el = itemsRef.current[activeIdx]
  24. if (!(nav) || !(el) || activeIdx < 0)
  25. return
  26. const navRect = nav.getBoundingClientRect ()
  27. const elRect = el.getBoundingClientRect ()
  28. setHl ({ left: elRect.left - navRect.left,
  29. width: elRect.width,
  30. visible: true })
  31. }
  32. const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
  33. left: 0,
  34. width: 0,
  35. visible: false })
  36. const [menuOpen, setMenuOpen] = useState (false)
  37. const [openItemIdx, setOpenItemIdx] = useState (-1)
  38. const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
  39. const wikiIdStr = String (wikiId ?? '')
  40. const { data: wikiPage } = useQuery ({
  41. enabled: Boolean (wikiIdStr),
  42. queryKey: wikiKeys.show (wikiIdStr, { }),
  43. queryFn: () => fetchWikiPage (wikiIdStr, { }) })
  44. const effectiveTitle = wikiPage?.title ?? ''
  45. const { data: tag } = useQuery ({
  46. enabled: Boolean (effectiveTitle),
  47. queryKey: tagsKeys.show (effectiveTitle),
  48. queryFn: () => fetchTagByName (effectiveTitle) })
  49. const postCount = tag?.postCount ?? 0
  50. const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId)
  51. const wikiTitle = location.pathname.split ('/')[2] ?? ''
  52. const menu: Menu = [
  53. { name: '広場', to: '/posts', subMenu: [
  54. { name: '一覧', to: '/posts' },
  55. { name: '検索', to: '/posts/search' },
  56. { name: '投稿追加', to: '/posts/new' },
  57. { name: '履歴', to: '/posts/changes' },
  58. { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
  59. { name: 'タグ', to: '/tags', subMenu: [
  60. { name: 'タグ一覧', to: '/tags', visible: true },
  61. { name: '別名タグ', to: '/tags/aliases', visible: false },
  62. { name: '上位タグ', to: '/tags/implications', visible: false },
  63. { name: 'ニコニコ連携', to: '/tags/nico' },
  64. { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
  65. // TODO: 本実装時に消す.
  66. // { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
  67. // { name: '一覧', to: '/theatres' }] },
  68. { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
  69. { name: '検索', to: '/wiki' },
  70. { name: '新規', to: '/wiki/new' },
  71. { name: '全体履歴', to: '/wiki/changes' },
  72. { name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
  73. { component: <Separator/>, visible: wikiPageFlg },
  74. { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
  75. visible: wikiPageFlg },
  76. { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
  77. { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
  78. { name: 'ユーザ', to: '/users', subMenu: [
  79. { name: '一覧', to: '/users', visible: false },
  80. { name: 'お前', to: `/users/${ user?.id }`, visible: false },
  81. { name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
  82. const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to))
  83. const prevActiveIdxRef = useRef<number> (activeIdx)
  84. if (activeIdx !== prevActiveIdxRef.current)
  85. {
  86. dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1
  87. prevActiveIdxRef.current = activeIdx
  88. }
  89. const dir = dirRef.current
  90. useLayoutEffect (() => {
  91. if (activeIdx < 0)
  92. return
  93. const raf = requestAnimationFrame (measure)
  94. const onResize = () => requestAnimationFrame (measure)
  95. addEventListener ('resize', onResize)
  96. return () => {
  97. cancelAnimationFrame (raf)
  98. removeEventListener ('resize', onResize)
  99. }
  100. }, [activeIdx])
  101. useEffect (() => {
  102. const unsubscribe = WikiIdBus.subscribe (setWikiId)
  103. return () => unsubscribe ()
  104. }, [])
  105. useEffect (() => {
  106. setMenuOpen (false)
  107. setOpenItemIdx (menu.findIndex (item => (
  108. location.pathname.startsWith (item.base || item.to))))
  109. }, [location])
  110. return (
  111. <>
  112. <nav className="px-3 flex justify-between items-center w-full min-h-[48px]
  113. bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
  114. <div className="flex items-center gap-2 h-full">
  115. <PrefetchLink
  116. to="/posts"
  117. className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
  118. dark:text-pink-300 dark:hover:text-pink-100"
  119. onClick={() => {
  120. scroll (0, 0)
  121. }}>
  122. ぼざクリ タグ広場
  123. </PrefetchLink>
  124. <div ref={navRef} className="relative hidden md:flex h-full items-center">
  125. <div aria-hidden
  126. className={cn ('absolute top-1/2 -translate-y-1/2 h-full',
  127. 'bg-yellow-200 dark:bg-red-950',
  128. 'transition-[transform,width] duration-200 ease-out')}
  129. style={{ width: hl.width,
  130. transform: `translate(${ hl.left }px, -50%)`,
  131. opacity: hl.visible ? 1 : 0 }}/>
  132. {menu.map ((item, i) => (
  133. <PrefetchLink
  134. key={i}
  135. to={item.to}
  136. ref={(el: (HTMLAnchorElement | null)) => {
  137. itemsRef.current[i] = el
  138. }}
  139. className={cn ('relative z-10 flex h-full items-center px-5',
  140. (i === openItemIdx) && 'font-bold')}>
  141. {item.name}
  142. </PrefetchLink>))}
  143. </div>
  144. </div>
  145. <TopNavUser user={user}/>
  146. <a href="#"
  147. className="md:hidden ml-auto pr-4
  148. text-pink-600 hover:text-pink-400
  149. dark:text-pink-300 dark:hover:text-pink-100"
  150. onClick={ev => {
  151. ev.preventDefault ()
  152. setMenuOpen (!(menuOpen))
  153. }}>
  154. {menuOpen ? '×' : 'Menu'}
  155. </a>
  156. </nav>
  157. <div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
  158. items-center w-full min-h-[40px] overflow-hidden">
  159. <AnimatePresence initial={false} custom={dir}>
  160. <motion.div
  161. key={activeIdx}
  162. custom={dir}
  163. variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
  164. centre: { y: 0, opacity: 1 },
  165. exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
  166. className="absolute inset-0 flex items-center px-3"
  167. initial="enter"
  168. animate="centre"
  169. exit="exit"
  170. transition={{ duration: .2, ease: 'easeOut' }}>
  171. {(menu[activeIdx]?.subMenu ?? [])
  172. .filter (item => item.visible ?? true)
  173. .map ((item, i) => (
  174. 'component' in item
  175. ? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
  176. : (
  177. <PrefetchLink
  178. key={`l-${ i }`}
  179. to={item.to}
  180. className="h-full flex items-center px-3">
  181. {item.name}
  182. </PrefetchLink>)))}
  183. </motion.div>
  184. </AnimatePresence>
  185. </div>
  186. <AnimatePresence initial={false}>
  187. {menuOpen && (
  188. <motion.div
  189. key="spmenu"
  190. className={cn ('flex flex-col md:hidden',
  191. 'bg-yellow-200 dark:bg-red-975 items-start')}
  192. variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
  193. height: 0 },
  194. open: { clipPath: 'inset(0 0 0% 0)',
  195. height: 'auto' } }}
  196. initial="closed"
  197. animate="open"
  198. exit="closed"
  199. transition={{ duration: .2, ease: 'easeOut' }}>
  200. <Separator/>
  201. {menu.map ((item, i) => (
  202. <Fragment key={i}>
  203. <PrefetchLink
  204. to={i === openItemIdx ? item.to : '#'}
  205. className={cn ('w-full min-h-[40px] flex items-center pl-8',
  206. ((i === openItemIdx)
  207. && 'font-bold bg-yellow-50 dark:bg-red-950'))}
  208. onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
  209. if (i !== openItemIdx)
  210. {
  211. ev.preventDefault ()
  212. setOpenItemIdx (i)
  213. }
  214. }}>
  215. {item.name}
  216. </PrefetchLink>
  217. <AnimatePresence initial={false}>
  218. {i === openItemIdx && (
  219. <motion.div
  220. key={`sp-sub-${ i }`}
  221. className="w-full bg-yellow-50 dark:bg-red-950"
  222. variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
  223. height: 0,
  224. opacity: 0 },
  225. open: { clipPath: 'inset(0 0 0% 0)',
  226. height: 'auto',
  227. opacity: 1 } }}
  228. initial="closed"
  229. animate="open"
  230. exit="closed"
  231. transition={{ duration: .2, ease: 'easeOut' }}>
  232. {item.subMenu
  233. .filter (subItem => subItem.visible ?? true)
  234. .map ((subItem, j) => (
  235. 'component' in subItem
  236. ? (
  237. <Fragment key={`sp-c-${ i }-${ j }`}>
  238. {subItem.component}
  239. </Fragment>)
  240. : (
  241. <PrefetchLink
  242. key={`sp-l-${ i }-${ j }`}
  243. to={subItem.to}
  244. className="w-full min-h-[36px] flex items-center pl-12">
  245. {subItem.name}
  246. </PrefetchLink>)))}
  247. </motion.div>)}
  248. </AnimatePresence>
  249. </Fragment>))}
  250. <TopNavUser user={user} sp/>
  251. <Separator/>
  252. </motion.div>)}
  253. </AnimatePresence>
  254. </>)
  255. }) satisfies FC<Props>