eb975e5301
Merge branch 'main' into feature/140 #140 Merge remote-tracking branch 'origin/main' into feature/140 #140 #140 #140 #140 #140 Merge remote-tracking branch 'origin/main' into feature/140 #140 #140 #140 #140 #140 #140 #140 #140 #140 #140 #140 Merge remote-tracking branch 'origin/main' into feature/140 Merge remote-tracking branch 'origin/main' into feature/140 #140 ぼちぼち Merge remote-tracking branch 'origin/main' into feature/140 #140 #140 #140 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: #256
286 行
9.1 KiB
TypeScript
286 行
9.1 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
||
import { AnimatePresence, motion } from 'framer-motion'
|
||
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||
import { useLocation } from 'react-router-dom'
|
||
|
||
import Separator from '@/components/MenuSeparator'
|
||
import PrefetchLink from '@/components/PrefetchLink'
|
||
import TopNavUser from '@/components/TopNavUser'
|
||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||
import { fetchTagByName } from '@/lib/tags'
|
||
import { cn } from '@/lib/utils'
|
||
import { fetchWikiPage } from '@/lib/wiki'
|
||
|
||
import type { FC, MouseEvent } from 'react'
|
||
|
||
import type { Menu, User } from '@/types'
|
||
|
||
type Props = { user: User | null }
|
||
|
||
|
||
export default (({ user }: Props) => {
|
||
const location = useLocation ()
|
||
|
||
const dirRef = useRef<(-1) | 1> (1)
|
||
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
||
const navRef = useRef<HTMLDivElement | null> (null)
|
||
|
||
const measure = () => {
|
||
const nav = navRef.current
|
||
const el = itemsRef.current[activeIdx]
|
||
if (!(nav) || !(el) || activeIdx < 0)
|
||
return
|
||
|
||
const navRect = nav.getBoundingClientRect ()
|
||
const elRect = el.getBoundingClientRect ()
|
||
|
||
setHl ({ left: elRect.left - navRect.left,
|
||
width: elRect.width,
|
||
visible: true })
|
||
}
|
||
|
||
const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
|
||
left: 0,
|
||
width: 0,
|
||
visible: false })
|
||
const [menuOpen, setMenuOpen] = useState (false)
|
||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||
|
||
const wikiIdStr = String (wikiId ?? '')
|
||
|
||
const { data: wikiPage } = useQuery ({
|
||
enabled: Boolean (wikiIdStr),
|
||
queryKey: wikiKeys.show (wikiIdStr, { }),
|
||
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
|
||
|
||
const effectiveTitle = wikiPage?.title ?? ''
|
||
|
||
const { data: tag } = useQuery ({
|
||
enabled: Boolean (effectiveTitle),
|
||
queryKey: tagsKeys.show (effectiveTitle),
|
||
queryFn: () => fetchTagByName (effectiveTitle) })
|
||
|
||
const postCount = tag?.postCount ?? 0
|
||
|
||
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId)
|
||
const wikiTitle = location.pathname.split ('/')[2] ?? ''
|
||
const menu: Menu = [
|
||
{ name: '広場', to: '/posts', subMenu: [
|
||
{ name: '一覧', to: '/posts' },
|
||
{ name: '投稿追加', to: '/posts/new' },
|
||
{ name: '耕作履歴', to: '/posts/changes' },
|
||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||
{ name: 'タグ', to: '/tags', subMenu: [
|
||
{ name: 'タグ一覧', to: '/tags', visible: false },
|
||
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||
{ name: '検索', to: '/wiki' },
|
||
{ name: '新規', to: '/wiki/new' },
|
||
{ name: '全体履歴', to: '/wiki/changes' },
|
||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
|
||
{ component: <Separator/>, visible: wikiPageFlg },
|
||
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
|
||
visible: wikiPageFlg },
|
||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
||
{ name: 'ユーザ', to: '/users', subMenu: [
|
||
{ name: '一覧', to: '/users', visible: false },
|
||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
|
||
|
||
const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
||
|
||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||
|
||
if (activeIdx !== prevActiveIdxRef.current)
|
||
{
|
||
dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1
|
||
prevActiveIdxRef.current = activeIdx
|
||
}
|
||
|
||
const dir = dirRef.current
|
||
|
||
useLayoutEffect (() => {
|
||
if (activeIdx < 0)
|
||
return
|
||
|
||
const raf = requestAnimationFrame (measure)
|
||
const onResize = () => requestAnimationFrame (measure)
|
||
|
||
addEventListener ('resize', onResize)
|
||
return () => {
|
||
cancelAnimationFrame (raf)
|
||
removeEventListener ('resize', onResize)
|
||
}
|
||
}, [activeIdx])
|
||
|
||
useEffect (() => {
|
||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||
return () => unsubscribe ()
|
||
}, [])
|
||
|
||
useEffect (() => {
|
||
setMenuOpen (false)
|
||
setOpenItemIdx (menu.findIndex (item => (
|
||
location.pathname.startsWith (item.base || item.to))))
|
||
}, [location])
|
||
|
||
return (
|
||
<>
|
||
<nav className="px-3 flex justify-between items-center w-full min-h-[48px]
|
||
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
|
||
<div className="flex items-center gap-2 h-full">
|
||
<PrefetchLink
|
||
to="/posts"
|
||
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
|
||
dark:text-pink-300 dark:hover:text-pink-100"
|
||
onClick={() => {
|
||
scroll (0, 0)
|
||
}}>
|
||
ぼざクリ タグ広場
|
||
</PrefetchLink>
|
||
|
||
<div ref={navRef} className="relative hidden md:flex h-full items-center">
|
||
<div aria-hidden
|
||
className={cn ('absolute top-1/2 -translate-y-1/2 h-full',
|
||
'bg-yellow-200 dark:bg-red-950',
|
||
'transition-[transform,width] duration-200 ease-out')}
|
||
style={{ width: hl.width,
|
||
transform: `translate(${ hl.left }px, -50%)`,
|
||
opacity: hl.visible ? 1 : 0 }}/>
|
||
|
||
{menu.map ((item, i) => (
|
||
<PrefetchLink
|
||
key={i}
|
||
to={item.to}
|
||
ref={(el: (HTMLAnchorElement | null)) => {
|
||
itemsRef.current[i] = el
|
||
}}
|
||
className={cn ('relative z-10 flex h-full items-center px-5',
|
||
(i === openItemIdx) && 'font-bold')}>
|
||
{item.name}
|
||
</PrefetchLink>))}
|
||
</div>
|
||
</div>
|
||
|
||
<TopNavUser user={user}/>
|
||
|
||
<a href="#"
|
||
className="md:hidden ml-auto pr-4
|
||
text-pink-600 hover:text-pink-400
|
||
dark:text-pink-300 dark:hover:text-pink-100"
|
||
onClick={ev => {
|
||
ev.preventDefault ()
|
||
setMenuOpen (!(menuOpen))
|
||
}}>
|
||
{menuOpen ? '×' : 'Menu'}
|
||
</a>
|
||
</nav>
|
||
|
||
<div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
|
||
items-center w-full min-h-[40px] overflow-hidden">
|
||
<AnimatePresence initial={false} custom={dir}>
|
||
<motion.div
|
||
key={activeIdx}
|
||
custom={dir}
|
||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||
centre: { y: 0, opacity: 1 },
|
||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||
className="absolute inset-0 flex items-center px-3"
|
||
initial="enter"
|
||
animate="centre"
|
||
exit="exit"
|
||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||
{(menu[activeIdx]?.subMenu ?? [])
|
||
.filter (item => item.visible ?? true)
|
||
.map ((item, i) => (
|
||
'component' in item
|
||
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
|
||
: (
|
||
<PrefetchLink
|
||
key={`l-${ i }`}
|
||
to={item.to}
|
||
className="h-full flex items-center px-3">
|
||
{item.name}
|
||
</PrefetchLink>)))}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
<AnimatePresence initial={false}>
|
||
{menuOpen && (
|
||
<motion.div
|
||
key="spmenu"
|
||
className={cn ('flex flex-col md:hidden',
|
||
'bg-yellow-200 dark:bg-red-975 items-start')}
|
||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||
height: 0 },
|
||
open: { clipPath: 'inset(0 0 0% 0)',
|
||
height: 'auto' } }}
|
||
initial="closed"
|
||
animate="open"
|
||
exit="closed"
|
||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||
<Separator/>
|
||
{menu.map ((item, i) => (
|
||
<Fragment key={i}>
|
||
<PrefetchLink
|
||
to={i === openItemIdx ? item.to : '#'}
|
||
className={cn ('w-full min-h-[40px] flex items-center pl-8',
|
||
((i === openItemIdx)
|
||
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
|
||
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
|
||
if (i !== openItemIdx)
|
||
{
|
||
ev.preventDefault ()
|
||
setOpenItemIdx (i)
|
||
}
|
||
}}>
|
||
{item.name}
|
||
</PrefetchLink>
|
||
|
||
<AnimatePresence initial={false}>
|
||
{i === openItemIdx && (
|
||
<motion.div
|
||
key={`sp-sub-${ i }`}
|
||
className="w-full bg-yellow-50 dark:bg-red-950"
|
||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||
height: 0,
|
||
opacity: 0 },
|
||
open: { clipPath: 'inset(0 0 0% 0)',
|
||
height: 'auto',
|
||
opacity: 1 } }}
|
||
initial="closed"
|
||
animate="open"
|
||
exit="closed"
|
||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||
{item.subMenu
|
||
.filter (subItem => subItem.visible ?? true)
|
||
.map ((subItem, j) => (
|
||
'component' in subItem
|
||
? (
|
||
<Fragment key={`sp-c-${ i }-${ j }`}>
|
||
{subItem.component}
|
||
</Fragment>)
|
||
: (
|
||
<PrefetchLink
|
||
key={`sp-l-${ i }-${ j }`}
|
||
to={subItem.to}
|
||
className="w-full min-h-[36px] flex items-center pl-12">
|
||
{subItem.name}
|
||
</PrefetchLink>)))}
|
||
</motion.div>)}
|
||
</AnimatePresence>
|
||
</Fragment>))}
|
||
<TopNavUser user={user} sp/>
|
||
<Separator/>
|
||
</motion.div>)}
|
||
</AnimatePresence>
|
||
</>)
|
||
}) satisfies FC<Props>
|