import { useQuery } from '@tanstack/react-query' import { AnimatePresence, motion } from 'framer-motion' import { Globe } from 'lucide-react' import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import LocalisedText, { formatUIString } from '@/components/LocalisedText' import Separator from '@/components/MenuSeparator' import PrefetchLink from '@/components/PrefetchLink' import TopNavUser from '@/components/TopNavUser' import TranslatedText from '@/components/TranslatedText' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { isLocaleCode, localeFromPath, switchLocalePath, unlocalisedPath } from '@/lib/locales' import { uiMessage, uiMessageLanguage } from '@/lib/messages' import { useUITranslation } from '@/lib/useUITranslation' import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { canEditContent } from '@/lib/users' import { fetchTag, fetchTagByName } from '@/lib/tags' import { fetchMaterial } from '@/lib/materials' import { cn } from '@/lib/utils' import { fetchWikiPage } from '@/lib/wiki' import type { FC, MouseEvent } from 'react' import type { AvailableLocale } from '@/lib/locales' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types' type Props = { user: User | null; locales: AvailableLocale[] } type NavDensity = 'auto' | 'comfortable' | 'compact' export const menuOutline = ( { tag, material, wikiId, user, pathName: fullPathname }: { tag?: Tag | null material?: Material | null wikiId: number | null user: User | null pathName: string }, ): Menu => { const pathName = unlocalisedPath (fullPathname) const locale = localeFromPath (fullPathname) ?? 'ja' const message = ( key: Parameters[1], values?: Record, ) => ({ name: uiMessage (locale, key, values), nameLanguage: uiMessageLanguage (locale, key) }) const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0 const editable = canEditContent (user) const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId) const wikiTitle = pathName.split ('/')[2] ?? '' const tagFlg = /^\/tags\/\d+/.test (pathName) const materialFlg = /^\/materials\/\d+/.test (pathName) const helpVsbl = /\/help\/((?!changes)[^/?]+)/.test (pathName) const helpIdentifier = pathName.split ('/')[2] ?? '' return [ { ...message ('posts'), to: '/posts', subMenu: [ { ...message ('list'), to: '/posts' }, { ...message ('search'), to: '/posts/search' }, { ...message ('add'), to: '/posts/new', visible: editable }, { ...message ('allHistory'), to: '/posts/changes' }, { ...message ('help'), to: '/wiki/ヘルプ:広場' }] }, { ...message ('tags'), to: '/tags', subMenu: [ { ...message ('master'), to: '/tags' }, { ...message ('nicoLinks'), to: '/tags/nico' }, { ...message ('allHistory'), to: '/tags/changes' }, { ...message ('help'), to: '/wiki/ヘルプ:タグ' }, { component: , visible: tagFlg }, { ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, visible: tagFlg }, { ...message ('history'), to: `/tags/changes?id=${ tag?.id }`, visible: tagFlg && tag?.category !== 'nico' }] }, { ...message ('materials'), to: '/materials', visible: true, subMenu: [ { ...message ('list'), to: '/materials' }, { ...message ('add'), to: '/materials/new', visible: editable }, { ...message ('suppressions'), to: '/materials/suppressions', visible: user?.role === 'admin' }, { ...message ('allHistory'), to: '/materials/changes' }, { ...message ('help'), to: '/wiki/ヘルプ:素材管理' }, { component: , visible: materialFlg }, { ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`, visible: materialFlg && Boolean (material?.tag) }, { ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`, visible: materialFlg }] }, { ...message ('wiki'), to: '/wiki', subMenu: [ { ...message ('search'), to: '/wiki' }, { ...message ('create'), to: '/wiki/new', visible: editable }, { ...message ('allHistory'), to: '/wiki/changes' }, { ...message ('help'), to: '/wiki/ヘルプ:Wiki' }, { component: , visible: wikiPageFlg }, { ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`, visible: wikiPageFlg }, { ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg && editable }] }, { ...message ('help'), to: '/help/home', base: '/help', subMenu: [ { ...message ('search'), to: '/help' }, { ...message ('allHistory'), to: '/help/changes' }, { component: , visible: helpVsbl }, { ...message ('history'), to: `/help/changes?identifier=${ helpIdentifier }`, visible: helpVsbl }] }, { ...message ('entertainment'), visible: false, subMenu: [ { ...message ('theatre'), to: '/theatres/1' }, { ...message ('gekanator'), to: '/gekanator' }] }, { ...message ('users'), to: '/users/settings', visible: false, subMenu: [ { ...message ('list'), to: '/users', visible: false }, { ...message ('currentUser'), to: `/users/${ user?.id }`, visible: false }, { ...message ('settings'), to: '/users/settings', visible: Boolean (user) }] }, { ...message ('legal'), visible: false, subMenu: [ { ...message ('terms'), to: '/tos' }] }] } const TopNav: FC = ({ user, locales }) => { const { message, label } = useUITranslation ('shared') const locale = useLocale () const localeName = locales.find (entry => entry.code === locale)?.name const navigate = useLocaleNavigate () const location = useLocation () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const animationsOff = animationMode === 'off' const reducedAnimations = animationMode === 'reduced' const dirRef = useRef<(-1) | 1> (1) const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([]) const navRef = useRef (null) const layoutRef = useRef (null) const barRef = useRef (null) const submenuContentRef = useRef (null) const highlightTargetRef = useRef (-1) const measure = (idx: number) => { const nav = navRef.current const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx] if (nav == null || el == null || nav.offsetWidth === 0 || layoutRef.current?.dataset.desktop !== 'true') { setHL (current => current.visible ? { left: 0, width: 0, visible: false } : current) return } // Layout offsets exclude Framer Motion's temporary projection transforms. let left = 0 let node: HTMLElement | null = el while (node != null && node !== nav) { left += node.offsetLeft const parent: Element | null = node.offsetParent node = parent instanceof HTMLElement ? parent : null } const width = node === nav ? el.offsetWidth : 0 const visible = width > 0 setHL (current => (current.left === left && current.width === width && current.visible === visible) ? current : { left, width, visible }) } const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({ left: 0, width: 0, visible: false }) const [menuOpen, setMenuOpen] = useState (false) const menuToggleLabel = message (menuOpen ? 'dialogue.close' : 'navigation:menuToggle') const [desktop, setDesktop] = useState (false) const [density, setDensity] = useState ('auto') const [moreVsbl, setMoreVsbl] = useState (false) const [openItemIdx, setOpenItemIdx] = useState (-1) const [submenuHeight, setSubmenuHeight] = useState (0) const [wikiId, setWikiId] = useState (WikiIdBus.get ()) const wikiIdStr = String (wikiId ?? '') const { data: wikiPage } = useQuery ({ enabled: Boolean (wikiIdStr), queryKey: wikiKeys.show (wikiIdStr, { }, locale), queryFn: () => fetchWikiPage (wikiIdStr, { }, locale) }) const pathname = unlocalisedPath (location.pathname) const tagFlg = /^\/tags\/\d+/.test (pathname) const materialFlg = /^\/materials\/\d+/.test (pathname) const effectiveTitle = (((tagFlg || materialFlg) ? pathname.split ('/')[2] : wikiPage?.title) ?? '') const { data: tag } = useQuery ({ enabled: Boolean (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle, locale), queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle, locale) }) const { data: material } = useQuery ({ enabled: Boolean (effectiveTitle), queryKey: materialsKeys.show (effectiveTitle, locale), queryFn: () => fetchMaterial (effectiveTitle, locale) }) const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname }) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) const menuLabels = visibleMenu.map (item => item.name).join ('\u0000') const moreMenu = menu.filter (item => !(item.visible ?? true) || item.subMenu.filter (subItem => subItem.visible ?? true).length > 0) const activeIdx = visibleMenu.findIndex (item => pathname.startsWith (item.base || item.to)) highlightTargetRef.current = moreVsbl ? -1 : activeIdx const activeSubmenuId = `submenu-${ menu.indexOf (visibleMenu[activeIdx]) }` const topNavTransition = animationsOff ? { duration: 0 } : (reducedAnimations ? { duration: .08, ease: 'linear' as const } : { duration: .2, ease: 'easeOut' as const }) const opacityTransition = animationsOff ? { duration: 0 } : (reducedAnimations ? { duration: .08 } : { duration: .12 }) const highlightTransitionClass = animationsOff ? undefined : (reducedAnimations ? 'transition-[transform,width] duration-75 ease-linear' : 'transition-[transform,width] duration-200 ease-out') const prevActiveIdxRef = useRef (activeIdx) if (activeIdx !== prevActiveIdxRef.current) { dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1 prevActiveIdxRef.current = activeIdx } const dir = dirRef.current useLayoutEffect (() => { const layout = layoutRef.current const bar = barRef.current const primary = bar?.querySelector ('.top-nav-primary') if (layout == null || bar == null || primary == null) return const account = bar.querySelector ('.top-nav-account') let raf = 0 let active = true const update = () => { if (!(window.matchMedia ('(min-width: 768px)').matches)) { setDesktop (false) setDensity ('auto') return } // Measure the same desktop elements while the mobile layout is active. layout.setAttribute ('data-measuring', '') const previousDensity = layout.getAttribute ('data-density') ?? 'auto' const fittingDensity = (() => { try { for (const candidate of ['auto', 'comfortable', 'compact'] as const) { layout.setAttribute ('data-density', candidate) const style = getComputedStyle (bar) const primaryWidth = Math.max ( primary.getBoundingClientRect ().width, primary.scrollWidth) const accountWidth = account == null ? 0 : Math.max (account.getBoundingClientRect ().width, account.scrollWidth) const required = primaryWidth + accountWidth + parseFloat (style.paddingLeft) + parseFloat (style.paddingRight) + (account == null ? 0 : parseFloat (style.columnGap)) if (Math.ceil (required) <= bar.clientWidth) return candidate } return null } finally { layout.setAttribute ('data-density', previousDensity) layout.removeAttribute ('data-measuring') } }) () setDensity (fittingDensity ?? 'compact') setDesktop (fittingDensity != null) if (fittingDensity != null) measure (moreVsbl ? -1 : activeIdx) } const schedule = () => { if (!(active)) return cancelAnimationFrame (raf) raf = requestAnimationFrame (update) } const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule) observer?.observe (bar) observer?.observe (primary) for (const item of itemsRef.current) if (item != null) observer?.observe (item) if (account != null) observer?.observe (account) update () addEventListener ('resize', schedule) document.fonts?.addEventListener ('loadingdone', schedule) void document.fonts?.ready.then (schedule) return () => { active = false cancelAnimationFrame (raf) observer?.disconnect () removeEventListener ('resize', schedule) document.fonts?.removeEventListener ('loadingdone', schedule) } }, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels]) useLayoutEffect (() => { measure (moreVsbl ? -1 : activeIdx) if (!(desktop)) setMoreVsbl (false) }, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels]) useEffect (() => { const unsubscribe = WikiIdBus.subscribe (setWikiId) return () => unsubscribe () }, []) useLayoutEffect (() => { const content = submenuContentRef.current if (content == null) return const update = () => { setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height)) } const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update) observer?.observe (content) update () addEventListener ('resize', update) return () => { observer?.disconnect () removeEventListener ('resize', update) } }, [activeIdx, moreVsbl, desktop, density, locale]) useEffect (() => { setMenuOpen (false) setOpenItemIdx (activeIdx) }, [activeIdx, location]) const localeSelector = ( ) return (
{ if (moreVsbl) setMoreVsbl (false) }} transition={{ layout: topNavTransition }} onAnimationComplete={() => { measure (highlightTargetRef.current) }} onLayoutAnimationComplete={() => { measure (highlightTargetRef.current) }}>
{moreVsbl ? ( moreMenu.map ((item, i) => (
measure (highlightTargetRef.current)} className="z-10 min-w-0 px-3 py-2 font-bold">

{item.name}

{item.subMenu .filter (subItem => subItem.visible ?? true) .map ((subItem, j) => ( 'component' in subItem ? ( {subItem.component} ) : ( setMoreVsbl (false)} className="top-nav-menu-link flex min-h-[40px] items-center px-3 py-2"> {subItem.name} )))}
))) : ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0 && (
{animationsOff ? (
{(visibleMenu[activeIdx]?.subMenu ?? []) .filter (item => item.visible ?? true) .map ((item, i) => ( 'component' in item ? (
{item.component}
) : (
{item.name}
)))}
) : ( ({ y: d * 24, opacity: 0 }), centre: { y: 0, opacity: 1 }, exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }} className="relative flex min-h-[40px] flex-wrap items-center px-3" initial="enter" animate="centre" exit="exit" transition={topNavTransition}> {(visibleMenu[activeIdx]?.subMenu ?? []) .filter (item => item.visible ?? true) .map ((item, i) => ( 'component' in item ? ( {item.component} ) : ( {item.name} )))} )}
))}
{animationsOff ? ( menuOpen && (
{visibleMenu.map ((item, i) => ( ) => { if (i !== openItemIdx) { ev.preventDefault () setOpenItemIdx (i) } }}> {item.name} {i === openItemIdx && (
{item.subMenu .filter (subItem => subItem.visible ?? true) .map ((subItem, j) => ( 'component' in subItem ? ( {subItem.component} ) : ( {subItem.name} )))}
)}
))}
)) : ( {menuOpen && ( {visibleMenu.map ((item, i) => ( ) => { if (i !== openItemIdx) { ev.preventDefault () setOpenItemIdx (i) } }}> {item.name} {i === openItemIdx && ( {item.subMenu .filter (subItem => subItem.visible ?? true) .map ((subItem, j) => ( 'component' in subItem ? ( {subItem.component} ) : ( {subItem.name} )))} )} ))} )} )}
) } export default TopNav