ぼざクリタグ広場 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.
 
 
 
 
 
 

289 lines
8.9 KiB

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