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

437 lines
15 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 { fetchTag, 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, MenuVisibleItem, Tag, User } from '@/types'
  15. type Props = { user: User | null }
  16. export const menuOutline = ({ tag, wikiId, user, pathName }: {
  17. tag?: Tag | null
  18. wikiId: number | null
  19. user: User | null,
  20. pathName: string }): Menu => {
  21. const postCount = tag?.postCount ?? 0
  22. const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId)
  23. const wikiTitle = pathName.split ('/')[2] ?? ''
  24. const tagFlg = /^\/tags\/\d+/.test (pathName)
  25. return [
  26. { name: '広場', to: '/posts', subMenu: [
  27. { name: '一覧', to: '/posts' },
  28. { name: '検索', to: '/posts/search' },
  29. { name: '追加', to: '/posts/new' },
  30. { name: '履歴', to: '/posts/changes' },
  31. { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
  32. { name: 'タグ', to: '/tags', subMenu: [
  33. { name: 'マスタ', to: '/tags' },
  34. { name: 'ニコニコ連携', to: '/tags/nico' },
  35. { name: '履歴', to: '/tags/changes' },
  36. { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
  37. { component: <Separator/>, visible: tagFlg },
  38. { name: `広場 (${ postCount || 0 })`,
  39. to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
  40. visible: tagFlg },
  41. { name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
  42. visible: tagFlg && tag?.category !== 'nico' }] },
  43. { name: '素材', to: '/materials', visible: false, subMenu: [
  44. { name: '一覧', to: '/materials' },
  45. { name: '検索', to: '/materials/search', visible: false },
  46. { name: '追加', to: '/materials/new' },
  47. { name: '履歴', to: '/materials/changes', visible: false },
  48. { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
  49. { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
  50. { name: <>第&thinsp;1&thinsp;会場</>, to: '/theatres/1' },
  51. { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
  52. { name: <>ニジカ放送局第&thinsp;1&thinsp;チャンネル</>,
  53. to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
  54. { name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
  55. { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
  56. { name: '検索', to: '/wiki' },
  57. { name: '新規', to: '/wiki/new' },
  58. { name: '全体履歴', to: '/wiki/changes' },
  59. { name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
  60. { component: <Separator/>, visible: wikiPageFlg },
  61. { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
  62. visible: wikiPageFlg },
  63. { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
  64. { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
  65. { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
  66. { name: '一覧', to: '/users', visible: false },
  67. { name: 'お前', to: `/users/${ user?.id }`, visible: false },
  68. { name: '設定', to: '/users/settings', visible: Boolean (user) }] },
  69. { name: '法規', visible: false, subMenu: [
  70. { name: '利用規約', to: '/tos' }] }]
  71. }
  72. export default (({ user }: Props) => {
  73. const location = useLocation ()
  74. const dirRef = useRef<(-1) | 1> (1)
  75. const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
  76. const navRef = useRef<HTMLDivElement | null> (null)
  77. const measure = (idx: number) => {
  78. const nav = navRef.current
  79. const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
  80. if (!(nav) || !(el))
  81. {
  82. setHL ({ left: 0, width: 0, visible: true })
  83. return
  84. }
  85. const navRect = nav.getBoundingClientRect ()
  86. const elRect = el.getBoundingClientRect ()
  87. setHL ({ left: elRect.left - navRect.left,
  88. width: elRect.width,
  89. visible: true })
  90. }
  91. const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
  92. left: 0,
  93. width: 0,
  94. visible: false })
  95. const [menuOpen, setMenuOpen] = useState (false)
  96. const [moreVsbl, setMoreVsbl] = useState (false)
  97. const [openItemIdx, setOpenItemIdx] = useState (-1)
  98. const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
  99. const wikiIdStr = String (wikiId ?? '')
  100. const { data: wikiPage } = useQuery ({
  101. enabled: Boolean (wikiIdStr),
  102. queryKey: wikiKeys.show (wikiIdStr, { }),
  103. queryFn: () => fetchWikiPage (wikiIdStr, { }) })
  104. const tagFlg = /^\/tags\/\d+/.test (location.pathname)
  105. const effectiveTitle = (tagFlg ? location.pathname.split ('/')[2] : wikiPage?.title) ?? ''
  106. const { data: tag } = useQuery ({
  107. enabled: Boolean (effectiveTitle),
  108. queryKey: tagsKeys.show (effectiveTitle),
  109. queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
  110. const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
  111. const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
  112. const activeIdx =
  113. visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
  114. const prevActiveIdxRef = useRef<number> (activeIdx)
  115. if (activeIdx !== prevActiveIdxRef.current)
  116. {
  117. dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1
  118. prevActiveIdxRef.current = activeIdx
  119. }
  120. const dir = dirRef.current
  121. useLayoutEffect (() => {
  122. const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
  123. const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
  124. addEventListener ('resize', onResize)
  125. return () => {
  126. cancelAnimationFrame (raf)
  127. removeEventListener ('resize', onResize)
  128. }
  129. })
  130. useEffect (() => {
  131. const unsubscribe = WikiIdBus.subscribe (setWikiId)
  132. return () => unsubscribe ()
  133. }, [activeIdx])
  134. useEffect (() => {
  135. setMenuOpen (false)
  136. setOpenItemIdx (activeIdx)
  137. }, [location])
  138. return (
  139. <>
  140. <nav className="px-3 flex justify-between items-center w-full
  141. bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
  142. <div className="flex items-center gap-2 h-12">
  143. <PrefetchLink
  144. to="/posts"
  145. className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
  146. dark:text-pink-300 dark:hover:text-pink-100"
  147. onClick={() => {
  148. scroll (0, 0)
  149. }}>
  150. ぼざクリ タグ広場
  151. </PrefetchLink>
  152. <div ref={navRef} className="relative hidden md:flex h-12 items-center">
  153. <div aria-hidden
  154. className={cn ('absolute inset-y-0 h-12',
  155. 'bg-yellow-200 dark:bg-red-950',
  156. 'transition-[transform,width] duration-200 ease-out')}
  157. style={{ width: hl.width,
  158. transform: `translateX(${ hl.left }px)`,
  159. opacity: hl.visible ? 1 : 0 }}/>
  160. {visibleMenu.map ((item, i) => (
  161. <motion.div
  162. key={item.to}
  163. layoutId={`menu-${ item.name }`}
  164. animate={{ opacity: moreVsbl ? 0 : 1 }}
  165. transition={{ opacity: { duration: .12 },
  166. layout: { duration: .2, ease: 'easeOut' } }}
  167. style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
  168. onMouseEnter={() => setMoreVsbl (false)}>
  169. <PrefetchLink
  170. to={item.to}
  171. ref={(el: (HTMLAnchorElement | null)) => {
  172. itemsRef.current[i] = el
  173. }}
  174. className={cn ('relative z-10 flex h-full items-center px-5',
  175. (i === openItemIdx) && 'font-bold')}>
  176. {item.name}
  177. </PrefetchLink>
  178. </motion.div>))}
  179. <PrefetchLink
  180. to="/more"
  181. ref={(el: (HTMLAnchorElement | null)) => {
  182. itemsRef.current[visibleMenu.length] = el
  183. }}
  184. onClick={() => setMoreVsbl (false)}
  185. onMouseEnter={() => {
  186. setMoreVsbl (true)
  187. measure (-1)
  188. }}
  189. className={cn ('relative z-10 flex h-full items-center px-5',
  190. (openItemIdx < 0 || moreVsbl) && 'font-bold')}>
  191. その他 &raquo;
  192. </PrefetchLink>
  193. </div>
  194. </div>
  195. <TopNavUser user={user}/>
  196. <a href="#"
  197. className="md:hidden ml-auto pr-4
  198. text-pink-600 hover:text-pink-400
  199. dark:text-pink-300 dark:hover:text-pink-100"
  200. onClick={ev => {
  201. ev.preventDefault ()
  202. setMenuOpen (!(menuOpen))
  203. }}>
  204. {menuOpen ? '×' : 'Menu'}
  205. </a>
  206. </nav>
  207. <AnimatePresence initial={false}>
  208. <motion.div
  209. key="submenu-shell"
  210. layout
  211. className="relative hidden md:block overflow-hidden
  212. bg-yellow-200 dark:bg-red-950"
  213. style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }}
  214. onMouseLeave={() => {
  215. if (moreVsbl)
  216. setMoreVsbl (false)
  217. }}
  218. transition={{ layout: { duration: .2, ease: 'easeOut' } }}
  219. onAnimationComplete={() => {
  220. measure (moreVsbl ? -1 : activeIdx)
  221. }}>
  222. {moreVsbl
  223. ? (
  224. menu.map ((item, i) => (
  225. <div key={i} className="relative h-[40px]">
  226. <div className="absolute inset-0 flex items-center px-3">
  227. <motion.div
  228. transition={{ duration: .2, ease: 'easeOut' }}
  229. {...((item.visible ?? true)
  230. ? { layoutId: `menu-${ item.name }` }
  231. : { initial: { x: 40, y: -40, opacity: 0 },
  232. animate: { x: 0, y: 0, opacity: 1 },
  233. exit: { x: 40, y: -40, opacity: 0 } })}
  234. className="z-10 h-full flex items-center px-3 font-bold w-24">
  235. <h2>{item.name}</h2>
  236. </motion.div>
  237. {item.subMenu
  238. .filter (subItem => subItem.visible ?? true)
  239. .map ((subItem, j) => (
  240. 'component' in subItem
  241. ? (
  242. <motion.div
  243. key={`c-${ i }-${ j }`}
  244. transition={{ duration: .2, ease: 'easeOut' }}
  245. {...((visibleMenu[activeIdx]?.name
  246. === item.name)
  247. ? { layoutId: `submenu-${ item.name }-${ j }` }
  248. : { initial: { y: -40, opacity: 0 },
  249. animate: { y: 0, opacity: 1 },
  250. exit: { y: -40, opacity: 0 } })}>
  251. {subItem.component}
  252. </motion.div>)
  253. : (
  254. <motion.div
  255. key={`l-${ i }-${ j }`}
  256. transition={{ duration: .2, ease: 'easeOut' }}
  257. {...((visibleMenu[activeIdx]?.name
  258. === item.name)
  259. ? { layoutId: `submenu-${ item.name }-${ j }` }
  260. : { initial: { y: -40, opacity: 0 },
  261. animate: { y: 0, opacity: 1 },
  262. exit: { y: -40, opacity: 0 } })}>
  263. <PrefetchLink
  264. to={subItem.to}
  265. target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
  266. onClick={() => setMoreVsbl (false)}
  267. className="h-full flex items-center px-3">
  268. {subItem.name}
  269. </PrefetchLink>
  270. </motion.div>)))}
  271. </div>
  272. </div>)))
  273. : ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
  274. && (
  275. <div className="relative h-[40px]">
  276. <AnimatePresence initial={false} custom={dir}>
  277. <motion.div
  278. key={activeIdx}
  279. custom={dir}
  280. variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
  281. centre: { y: 0, opacity: 1 },
  282. exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
  283. className="absolute inset-0 flex items-center px-3"
  284. initial="enter"
  285. animate="centre"
  286. exit="exit"
  287. transition={{ duration: .2, ease: 'easeOut' }}>
  288. {(visibleMenu[activeIdx]?.subMenu ?? [])
  289. .filter (item => item.visible ?? true)
  290. .map ((item, i) => (
  291. 'component' in item
  292. ? (
  293. <motion.div
  294. key={`c-${ i }`}
  295. transition={{ layout: { duration: .2, ease: 'easeOut' } }}
  296. layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
  297. {item.component}
  298. </motion.div>)
  299. : (
  300. <motion.div
  301. key={`l-${ i }`}
  302. transition={{ layout: { duration: .2, ease: 'easeOut' } }}
  303. layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
  304. <PrefetchLink
  305. to={item.to}
  306. target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
  307. className="h-full flex items-center px-3">
  308. {item.name}
  309. </PrefetchLink>
  310. </motion.div>)))}
  311. </motion.div>
  312. </AnimatePresence>
  313. </div>))}
  314. </motion.div>
  315. </AnimatePresence>
  316. <AnimatePresence initial={false}>
  317. {menuOpen && (
  318. <motion.div
  319. key="spmenu"
  320. className={cn ('flex flex-col md:hidden',
  321. 'bg-yellow-200 dark:bg-red-975 items-start')}
  322. variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
  323. height: 0 },
  324. open: { clipPath: 'inset(0 0 0% 0)',
  325. height: 'auto' } }}
  326. initial="closed"
  327. animate="open"
  328. exit="closed"
  329. transition={{ duration: .2, ease: 'easeOut' }}>
  330. <Separator/>
  331. {visibleMenu.map ((item, i) => (
  332. <Fragment key={i}>
  333. <PrefetchLink
  334. to={i === openItemIdx ? item.to : '#'}
  335. className={cn ('w-full min-h-[40px] flex items-center pl-8',
  336. ((i === openItemIdx)
  337. && 'font-bold bg-yellow-50 dark:bg-red-950'))}
  338. onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
  339. if (i !== openItemIdx)
  340. {
  341. ev.preventDefault ()
  342. setOpenItemIdx (i)
  343. }
  344. }}>
  345. {item.name}
  346. </PrefetchLink>
  347. <AnimatePresence initial={false}>
  348. {i === openItemIdx && (
  349. <motion.div
  350. key={`sp-sub-${ i }`}
  351. className="w-full bg-yellow-50 dark:bg-red-950"
  352. variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
  353. height: 0,
  354. opacity: 0 },
  355. open: { clipPath: 'inset(0 0 0% 0)',
  356. height: 'auto',
  357. opacity: 1 } }}
  358. initial="closed"
  359. animate="open"
  360. exit="closed"
  361. transition={{ duration: .2, ease: 'easeOut' }}>
  362. {item.subMenu
  363. .filter (subItem => subItem.visible ?? true)
  364. .map ((subItem, j) => (
  365. 'component' in subItem
  366. ? (
  367. <Fragment key={`sp-c-${ i }-${ j }`}>
  368. {subItem.component}
  369. </Fragment>)
  370. : (
  371. <PrefetchLink
  372. key={`sp-l-${ i }-${ j }`}
  373. to={subItem.to}
  374. target={subItem.to.slice (0, 2) === '//'
  375. ? '_blank'
  376. : undefined}
  377. className="w-full min-h-[36px] flex items-center pl-12">
  378. {subItem.name}
  379. </PrefetchLink>)))}
  380. </motion.div>)}
  381. </AnimatePresence>
  382. </Fragment>))}
  383. <PrefetchLink
  384. to="/more"
  385. ref={(el: (HTMLAnchorElement | null)) => {
  386. itemsRef.current[visibleMenu.length] = el
  387. }}
  388. className={cn ('w-full min-h-[40px] flex items-center pl-8',
  389. ((openItemIdx < 0)
  390. && 'font-bold bg-yellow-50 dark:bg-red-950'))}>
  391. その他 &raquo;
  392. </PrefetchLink>
  393. <TopNavUser user={user} sp/>
  394. <Separator/>
  395. </motion.div>)}
  396. </AnimatePresence>
  397. </>)
  398. }) satisfies FC<Props>