Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
+354
-162
@@ -1,12 +1,23 @@
|
||||
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 { toast } from '@/components/ui/use-toast'
|
||||
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'
|
||||
@@ -17,20 +28,33 @@ import { fetchWikiPage } from '@/lib/wiki'
|
||||
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
|
||||
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
|
||||
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
type Props = {
|
||||
user: User | null
|
||||
locales: AvailableLocale[]
|
||||
resolveLocaleSwitch?: (locale: LocaleCode) => Promise<string> }
|
||||
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
||||
|
||||
|
||||
export const menuOutline = (
|
||||
{ tag, material, wikiId, user, pathName }: {
|
||||
{ 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<typeof uiMessage>[1],
|
||||
values?: Record<string, string | number>,
|
||||
) => ({ name: uiMessage (locale, key, values),
|
||||
nameLanguage: uiMessageLanguage (locale, key) })
|
||||
|
||||
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
|
||||
const editable = canEditContent (user)
|
||||
|
||||
@@ -41,75 +65,89 @@ export const menuOutline = (
|
||||
|
||||
const materialFlg = /^\/materials\/\d+/.test (pathName)
|
||||
|
||||
const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName)
|
||||
const helpVsbl = /\/help\/((?!changes)[^/?]+)/.test (pathName)
|
||||
const helpIdentifier = pathName.split ('/')[2] ?? ''
|
||||
|
||||
return [
|
||||
{ name: '広場', to: '/posts', subMenu: [
|
||||
{ name: '一覧', to: '/posts' },
|
||||
{ name: '検索', to: '/posts/search' },
|
||||
{ name: '追加', to: '/posts/new', visible: editable },
|
||||
{ name: '全体履歴', to: '/posts/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||
{ name: 'タグ', to: '/tags', subMenu: [
|
||||
{ name: 'マスタ', to: '/tags' },
|
||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||
{ name: '全体履歴', to: '/tags/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
|
||||
{ ...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: <Separator/>, visible: tagFlg },
|
||||
{ name: `広場 (${ postCount || 0 })`,
|
||||
{ ...message ('postsWithCount', { count: postCount }),
|
||||
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
|
||||
visible: tagFlg },
|
||||
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
|
||||
{ ...message ('history'), to: `/tags/changes?id=${ tag?.id }`,
|
||||
visible: tagFlg && tag?.category !== 'nico' }] },
|
||||
{ name: '素材', to: '/materials', visible: true, subMenu: [
|
||||
{ name: '一覧', to: '/materials' },
|
||||
{ name: '追加', to: '/materials/new', visible: editable },
|
||||
{ name: '抑止', to: '/materials/suppressions',
|
||||
{ ...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' },
|
||||
{ name: '全体履歴', to: '/materials/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
|
||||
{ ...message ('allHistory'), to: '/materials/changes' },
|
||||
{ ...message ('help'), to: '/wiki/ヘルプ:素材管理' },
|
||||
{ component: <Separator/>, visible: materialFlg },
|
||||
{ name: `広場 (${ postCount || 0 })`,
|
||||
{ ...message ('postsWithCount', { count: postCount }),
|
||||
to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`,
|
||||
visible: materialFlg && Boolean (material?.tag) },
|
||||
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
|
||||
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
|
||||
visible: materialFlg }] },
|
||||
{ name: 'Wiki', to: '/wiki', subMenu: [
|
||||
{ name: '検索', to: '/wiki' },
|
||||
{ name: '新規', to: '/wiki/new', visible: editable },
|
||||
{ name: '全体履歴', to: '/wiki/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
|
||||
{ ...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: <Separator/>, visible: wikiPageFlg },
|
||||
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
|
||||
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
|
||||
visible: wikiPageFlg },
|
||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||
visible: wikiPageFlg && editable }] },
|
||||
{ name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [
|
||||
{ name: '検索', to: '/help' },
|
||||
{ name: '全体履歴', to: '/help/changes' },
|
||||
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
|
||||
{ ...message ('search'), to: '/help' },
|
||||
{ ...message ('allHistory'), to: '/help/changes' },
|
||||
{ component: <Separator/>, visible: helpVsbl },
|
||||
{ name: '履歴',
|
||||
{ ...message ('history'),
|
||||
to: `/help/changes?identifier=${ helpIdentifier }`,
|
||||
visible: helpVsbl }] },
|
||||
{ name: 'おたのしみ', visible: false, subMenu: [
|
||||
{ name: '上映会 (β)', to: '/theatres/1' },
|
||||
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
||||
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
||||
{ name: '一覧', to: '/users', visible: false },
|
||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] },
|
||||
{ name: '法規', visible: false, subMenu: [
|
||||
{ name: '利用規約', to: '/tos' }] }]
|
||||
{ ...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<Props> = ({ user }) => {
|
||||
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
const { message, label } = useUITranslation ('shared')
|
||||
const locale = useLocale ()
|
||||
const localeName = locales.find (entry => entry.code === locale)?.name
|
||||
const navigate = useLocaleNavigate ()
|
||||
const location = useLocation ()
|
||||
const currentLocation = useRef (location)
|
||||
currentLocation.current = location
|
||||
const localeSwitchRequest = useRef (0)
|
||||
const [switchingLocale, setSwitchingLocale] = useState (false)
|
||||
useEffect (() => {
|
||||
++localeSwitchRequest.current
|
||||
setSwitchingLocale (false)
|
||||
return () => { ++localeSwitchRequest.current }
|
||||
}, [location.key])
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const animationsOff = animationMode === 'off'
|
||||
const [layoutReady, setLayoutReady] = useState (false)
|
||||
const animationsOff = animationMode === 'off' || !layoutReady
|
||||
const reducedAnimations = animationMode === 'reduced'
|
||||
|
||||
const dirRef = useRef<(-1) | 1> (1)
|
||||
@@ -117,23 +155,35 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
const navRef = useRef<HTMLDivElement | null> (null)
|
||||
const layoutRef = useRef<HTMLDivElement | null> (null)
|
||||
const barRef = useRef<HTMLElement | null> (null)
|
||||
const submenuContentRef = useRef<HTMLDivElement | null> (null)
|
||||
const highlightTargetRef = useRef (-1)
|
||||
|
||||
const measure = (idx: number) => {
|
||||
const nav = navRef.current
|
||||
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
|
||||
|
||||
if (!(nav) || !(el))
|
||||
{
|
||||
setHL ({ left: 0, width: 0, visible: true })
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
const navRect = nav.getBoundingClientRect ()
|
||||
const elRect = el.getBoundingClientRect ()
|
||||
|
||||
setHL ({ left: elRect.left - navRect.left,
|
||||
width: elRect.width,
|
||||
visible: true })
|
||||
// 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 }> ({
|
||||
@@ -141,62 +191,67 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
width: 0,
|
||||
visible: false })
|
||||
const [menuOpen, setMenuOpen] = useState (false)
|
||||
const [desktop, setDesktop] = useState (false)
|
||||
const menuToggleLabel = message (menuOpen ? 'dialogue.close' : 'navigation:menuToggle')
|
||||
const [desktop, setDesktop] = useState<boolean | null> (null)
|
||||
const [density, setDensity] = useState<NavDensity> ('auto')
|
||||
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||
const [submenuHeight, setSubmenuHeight] = useState (0)
|
||||
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, { }) })
|
||||
queryKey: wikiKeys.show (wikiIdStr, { }, locale),
|
||||
queryFn: () => fetchWikiPage (wikiIdStr, { }, locale) })
|
||||
|
||||
const tagFlg = /^\/tags\/\d+/.test (location.pathname)
|
||||
const materialFlg = /^\/materials\/\d+/.test (location.pathname)
|
||||
const pathname = unlocalisedPath (location.pathname)
|
||||
const tagFlg = /^\/tags\/\d+/.test (pathname)
|
||||
const materialFlg = /^\/materials\/\d+/.test (pathname)
|
||||
const effectiveTitle = (((tagFlg || materialFlg)
|
||||
? location.pathname.split ('/')[2]
|
||||
? pathname.split ('/')[2]
|
||||
: wikiPage?.title)
|
||||
?? '')
|
||||
|
||||
const { data: tag } = useQuery ({
|
||||
enabled: Boolean (effectiveTitle),
|
||||
queryKey: tagsKeys.show (effectiveTitle),
|
||||
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
|
||||
queryKey: tagsKeys.show (effectiveTitle, locale),
|
||||
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle, locale) })
|
||||
|
||||
const { data: material } = useQuery ({
|
||||
enabled: Boolean (effectiveTitle),
|
||||
queryKey: materialsKeys.show (effectiveTitle),
|
||||
queryFn: () => fetchMaterial (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 => location.pathname.startsWith (item.base || item.to))
|
||||
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
|
||||
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 }
|
||||
: (reducedAnimations
|
||||
? { duration: .08, ease: 'linear' as const }
|
||||
: { duration: .2, ease: 'easeOut' as const })
|
||||
const opacityTransition =
|
||||
animationsOff
|
||||
? { duration: 0 }
|
||||
: reducedAnimations
|
||||
? { duration: .08 }
|
||||
: { duration: .12 }
|
||||
: (reducedAnimations
|
||||
? { duration: .08 }
|
||||
: { duration: .12 })
|
||||
const highlightTransitionClass =
|
||||
animationsOff
|
||||
? undefined
|
||||
: reducedAnimations
|
||||
? 'transition-[transform,width] duration-75 ease-linear'
|
||||
: 'transition-[transform,width] duration-200 ease-out'
|
||||
: (reducedAnimations
|
||||
? 'transition-[transform,width] duration-75 ease-linear'
|
||||
: 'transition-[transform,width] duration-200 ease-out')
|
||||
|
||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||
|
||||
@@ -215,7 +270,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
if (layout == null || bar == null || primary == null)
|
||||
return
|
||||
|
||||
const userLink = bar.querySelector<HTMLElement> ('.top-nav-user-desktop')
|
||||
const account = bar.querySelector<HTMLElement> ('.top-nav-account')
|
||||
let raf = 0
|
||||
let active = true
|
||||
|
||||
@@ -237,11 +292,16 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
{
|
||||
layout.setAttribute ('data-density', candidate)
|
||||
const style = getComputedStyle (bar)
|
||||
const required = primary.getBoundingClientRect ().width
|
||||
+ (userLink?.getBoundingClientRect ().width ?? 0)
|
||||
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)
|
||||
+ (userLink == null ? 0 : parseFloat (style.columnGap))
|
||||
if (required <= bar.clientWidth)
|
||||
+ (account == null ? 0 : parseFloat (style.columnGap))
|
||||
if (Math.ceil (required) <= bar.clientWidth)
|
||||
return candidate
|
||||
}
|
||||
return null
|
||||
@@ -269,8 +329,11 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
|
||||
observer?.observe (bar)
|
||||
observer?.observe (primary)
|
||||
if (userLink != null)
|
||||
observer?.observe (userLink)
|
||||
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)
|
||||
@@ -283,22 +346,117 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
removeEventListener ('resize', schedule)
|
||||
document.fonts?.removeEventListener ('loadingdone', schedule)
|
||||
}
|
||||
}, [activeIdx, moreVsbl, desktop, density, user?.name])
|
||||
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
|
||||
|
||||
useEffect (() => {
|
||||
if (desktop == null || layoutReady)
|
||||
return
|
||||
let active = true
|
||||
let frame = 0
|
||||
void (document.fonts?.ready ?? Promise.resolve ()).then (() => {
|
||||
if (!active)
|
||||
return
|
||||
frame = requestAnimationFrame (() => {
|
||||
frame = requestAnimationFrame (() => {
|
||||
if (active)
|
||||
setLayoutReady (true)
|
||||
})
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
cancelAnimationFrame (frame)
|
||||
}
|
||||
}, [desktop, density, layoutReady])
|
||||
|
||||
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 = (
|
||||
<label className="top-nav-menu-link relative flex h-8 shrink-0 items-center gap-1
|
||||
rounded px-1 focus-within:ring-2">
|
||||
<Globe aria-hidden="true" size={16}/>
|
||||
<span className="top-nav-locale-name whitespace-nowrap text-sm">
|
||||
<LocalisedText language={locale}>{localeName}</LocalisedText>
|
||||
</span>
|
||||
<select
|
||||
aria-label={formatUIString (localeName ?? '', locale)}
|
||||
value={locale}
|
||||
disabled={switchingLocale}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
onChange={async event => {
|
||||
const nextLocale = event.target.value
|
||||
if (isLocaleCode (nextLocale)
|
||||
&& locales.some (entry => entry.code === nextLocale))
|
||||
{
|
||||
const request = ++localeSwitchRequest.current
|
||||
setSwitchingLocale (true)
|
||||
try
|
||||
{
|
||||
const path = resolveLocaleSwitch == null
|
||||
? switchLocalePath (location, nextLocale)
|
||||
: await resolveLocaleSwitch (nextLocale)
|
||||
if (request === localeSwitchRequest.current
|
||||
&& currentLocation.current.key === location.key)
|
||||
navigate (path)
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (request === localeSwitchRequest.current)
|
||||
toast ({ title: label ('common:fetchError') })
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (request === localeSwitchRequest.current)
|
||||
setSwitchingLocale (false)
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{locales.map (entry => isLocaleCode (entry.code) && (
|
||||
<option key={entry.code} value={entry.code}>
|
||||
{formatUIString (entry.name, entry.code)}
|
||||
</option>))}
|
||||
</select>
|
||||
</label>)
|
||||
|
||||
return (
|
||||
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
|
||||
data-desktop={desktop} data-density={density}>
|
||||
<nav ref={barRef}
|
||||
data-desktop={desktop ?? undefined} data-density={density}
|
||||
data-layout-ready={layoutReady}>
|
||||
<nav ref={barRef} lang={uiMessageLanguage (locale, 'posts')}
|
||||
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
|
||||
<div className="top-nav-primary flex shrink-0 items-center h-12">
|
||||
<PrefetchLink
|
||||
@@ -307,7 +465,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
onClick={() => {
|
||||
scroll (0, 0)
|
||||
}}>
|
||||
ぼざクリ タグ広場
|
||||
{label ('labels.brand')}
|
||||
</PrefetchLink>
|
||||
|
||||
<div ref={navRef}
|
||||
@@ -323,14 +481,15 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
{visibleMenu.map ((item, i) => (
|
||||
<motion.div
|
||||
key={item.to}
|
||||
initial={false}
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
{...(animationsOff
|
||||
? { }
|
||||
: { layoutId: `menu-${ item.name }` })}
|
||||
animate={{ opacity: moreVsbl ? 0 : 1 }}
|
||||
layoutId={`menu-${ menu.indexOf (item) }`}
|
||||
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
|
||||
animate={layoutReady ? { opacity: moreVsbl ? 0 : 1 } : undefined}
|
||||
transition={{ opacity: opacityTransition,
|
||||
layout: topNavTransition }}
|
||||
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
|
||||
style={{ pointerEvents: moreVsbl ? 'none' : 'auto',
|
||||
opacity: layoutReady ? undefined : (moreVsbl ? 0 : 1) }}
|
||||
onMouseEnter={() => setMoreVsbl (false)}>
|
||||
<PrefetchLink
|
||||
to={item.to}
|
||||
@@ -339,7 +498,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
}}
|
||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
||||
(i === openItemIdx) && 'font-bold')}>
|
||||
{item.name}
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
</motion.div>))}
|
||||
<PrefetchLink
|
||||
@@ -355,58 +516,73 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
||||
'shrink-0 whitespace-nowrap',
|
||||
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
||||
その他 »
|
||||
<TranslatedText messageKey="more"/>
|
||||
</PrefetchLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TopNavUser user={user}/>
|
||||
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
|
||||
<TopNavUser user={user}/>
|
||||
{localeSelector}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
||||
ml-auto border-0 bg-transparent pr-4"
|
||||
onClick={() => {
|
||||
setMenuOpen (!(menuOpen))
|
||||
}}>
|
||||
{menuOpen ? '×' : 'Menu'}
|
||||
</button>
|
||||
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
|
||||
{menuOpen && localeSelector}
|
||||
<button
|
||||
type="button"
|
||||
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
||||
border-0 bg-transparent pr-4"
|
||||
aria-label={menuToggleLabel.text}
|
||||
lang={menuToggleLabel.language}
|
||||
onClick={() => {
|
||||
setMenuOpen (!(menuOpen))
|
||||
}}>
|
||||
{menuOpen ? '×' : label ('navigation:menuToggle')}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
key="submenu-shell"
|
||||
{...(animationsOff ? { }
|
||||
: { layout: true })}
|
||||
initial={false}
|
||||
layout
|
||||
className="top-nav-desktop-submenu relative z-20 shrink-0
|
||||
overflow-hidden top-nav-submenu"
|
||||
animate={{ height: submenuHeight }}
|
||||
animate={{ height: desktop == null ? 'auto' : submenuHeight }}
|
||||
onMouseLeave={() => {
|
||||
if (moreVsbl)
|
||||
setMoreVsbl (false)
|
||||
}}
|
||||
transition={{ layout: topNavTransition }}
|
||||
transition={{ ...topNavTransition, layout: topNavTransition }}
|
||||
onAnimationComplete={() => {
|
||||
measure (moreVsbl ? -1 : activeIdx)
|
||||
measure (highlightTargetRef.current)
|
||||
}}
|
||||
onLayoutAnimationComplete={() => {
|
||||
measure (highlightTargetRef.current)
|
||||
}}>
|
||||
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
|
||||
{moreVsbl
|
||||
? (
|
||||
moreMenu.map ((item, i) => (
|
||||
<div key={i} className="relative h-[40px]">
|
||||
<div className="absolute inset-0 flex items-center px-3
|
||||
overflow-x-auto whitespace-nowrap">
|
||||
<div key={i} className="contents">
|
||||
<motion.div
|
||||
transition={topNavTransition}
|
||||
{...(animationsOff
|
||||
? { }
|
||||
: ((item.visible ?? true)
|
||||
? { layoutId: `menu-${ item.name }` }
|
||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||
animate: { x: 0, y: 0, opacity: 1 },
|
||||
exit: { x: 40, y: -40, opacity: 0 } }))}
|
||||
className="z-10 h-full flex shrink-0 items-center px-3 font-bold w-28">
|
||||
<h2>{item.name}</h2>
|
||||
{...((item.visible ?? true)
|
||||
? { layoutId: `menu-${ menu.indexOf (item) }` }
|
||||
: (animationsOff ? { }
|
||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||
animate: { x: 0, y: 0, opacity: 1 },
|
||||
exit: { x: 40, y: -40, opacity: 0 } }))}
|
||||
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
|
||||
className="z-10 min-w-0 px-3 py-2 font-bold">
|
||||
<h2>
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</h2>
|
||||
</motion.div>
|
||||
<div className="top-nav-more-links flex min-w-0 flex-wrap items-center">
|
||||
{item.subMenu
|
||||
.filter (subItem => subItem.visible ?? true)
|
||||
.map ((subItem, j) => (
|
||||
@@ -414,76 +590,77 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
? (
|
||||
<motion.div
|
||||
key={`c-${ i }-${ j }`}
|
||||
className="min-w-0 max-w-full"
|
||||
transition={topNavTransition}
|
||||
{...(animationsOff
|
||||
? { }
|
||||
: ((visibleMenu[activeIdx]?.name
|
||||
=== item.name)
|
||||
? { layoutId: `submenu-${ item.name }-${ j }` }
|
||||
: { initial: { y: -40, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -40, opacity: 0 } }))}>
|
||||
{...(visibleMenu[activeIdx] === item
|
||||
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
||||
: (animationsOff ? { }
|
||||
: { initial: { y: -40, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -40, opacity: 0 } }))}>
|
||||
{subItem.component}
|
||||
</motion.div>)
|
||||
: (
|
||||
<motion.div
|
||||
key={`l-${ i }-${ j }`}
|
||||
className="min-w-0 max-w-full"
|
||||
transition={topNavTransition}
|
||||
{...(animationsOff
|
||||
? { }
|
||||
: ((visibleMenu[activeIdx]?.name
|
||||
=== item.name)
|
||||
? { layoutId: `submenu-${ item.name }-${ j }` }
|
||||
: { initial: { y: -40, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -40, opacity: 0 } }))}>
|
||||
{...(visibleMenu[activeIdx] === item
|
||||
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
||||
: (animationsOff ? { }
|
||||
: { initial: { y: -40, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -40, opacity: 0 } }))}>
|
||||
<PrefetchLink
|
||||
to={subItem.to}
|
||||
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||
onClick={() => setMoreVsbl (false)}
|
||||
className="top-nav-menu-link h-full flex items-center px-3">
|
||||
{subItem.name}
|
||||
className="top-nav-menu-link flex min-h-[40px] items-center px-3 py-2">
|
||||
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
|
||||
{subItem.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
</motion.div>)))}
|
||||
</div>
|
||||
</div>)))
|
||||
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
|
||||
&& (
|
||||
<div className="relative h-[40px]">
|
||||
<div className="relative min-h-[40px]">
|
||||
{animationsOff
|
||||
? (
|
||||
<div className="absolute inset-0 flex items-center px-3
|
||||
overflow-x-auto whitespace-nowrap">
|
||||
<div className="flex min-h-[40px] flex-wrap items-center px-3">
|
||||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||
.filter (item => item.visible ?? true)
|
||||
.map ((item, i) => (
|
||||
'component' in item
|
||||
? (
|
||||
<div key={`c-${ i }`}>
|
||||
<div key={`c-${ i }`} className="min-w-0 max-w-full">
|
||||
{item.component}
|
||||
</div>)
|
||||
: (
|
||||
<div key={`l-${ i }`}>
|
||||
<div key={`l-${ i }`} className="min-w-0 max-w-full">
|
||||
<PrefetchLink
|
||||
to={item.to}
|
||||
target={item.to.slice (0, 2) === '//'
|
||||
? '_blank'
|
||||
: undefined}
|
||||
className="top-nav-menu-link h-full flex items-center px-3">
|
||||
{item.name}
|
||||
className="top-nav-menu-link flex min-h-[40px]
|
||||
items-center px-3 py-2">
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
</div>)))}
|
||||
</div>)
|
||||
: (
|
||||
<AnimatePresence initial={false} custom={dir}>
|
||||
<AnimatePresence initial={false} custom={dir} mode="popLayout">
|
||||
<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
|
||||
overflow-x-auto whitespace-nowrap"
|
||||
className="relative flex min-h-[40px] flex-wrap items-center px-3"
|
||||
initial="enter"
|
||||
animate="centre"
|
||||
exit="exit"
|
||||
@@ -495,27 +672,33 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
? (
|
||||
<motion.div
|
||||
key={`c-${ i }`}
|
||||
className="min-w-0 max-w-full"
|
||||
transition={{ layout: topNavTransition }}
|
||||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||||
layoutId={`${ activeSubmenuId }-${ i }`}>
|
||||
{item.component}
|
||||
</motion.div>)
|
||||
: (
|
||||
<motion.div
|
||||
key={`l-${ i }`}
|
||||
className="min-w-0 max-w-full"
|
||||
transition={{ layout: topNavTransition }}
|
||||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||||
layoutId={`${ activeSubmenuId }-${ i }`}>
|
||||
<PrefetchLink
|
||||
to={item.to}
|
||||
target={item.to.slice (0, 2) === '//'
|
||||
? '_blank'
|
||||
: undefined}
|
||||
className="top-nav-menu-link h-full flex items-center px-3">
|
||||
{item.name}
|
||||
className="top-nav-menu-link flex min-h-[40px]
|
||||
items-center px-3 py-2">
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
</motion.div>)))}
|
||||
</motion.div>
|
||||
</AnimatePresence>)}
|
||||
</div>))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -540,7 +723,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
setOpenItemIdx (i)
|
||||
}
|
||||
}}>
|
||||
{item.name}
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
|
||||
{i === openItemIdx && (
|
||||
@@ -561,7 +746,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
? '_blank'
|
||||
: undefined}
|
||||
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
|
||||
{subItem.name}
|
||||
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
|
||||
{subItem.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>)))}
|
||||
</div>)}
|
||||
</Fragment>))}
|
||||
@@ -570,7 +757,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
||||
((openItemIdx < 0)
|
||||
&& 'top-nav-mobile-active font-bold'))}>
|
||||
その他 »
|
||||
<TranslatedText messageKey="more"/>
|
||||
</PrefetchLink>
|
||||
<TopNavUser user={user} sp/>
|
||||
<Separator/>
|
||||
@@ -605,7 +792,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
setOpenItemIdx (i)
|
||||
}
|
||||
}}>
|
||||
{item.name}
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
@@ -639,7 +828,10 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
? '_blank'
|
||||
: undefined}
|
||||
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
|
||||
{subItem.name}
|
||||
<LocalisedText
|
||||
language={subItem.nameLanguage ?? 'ja'}>
|
||||
{subItem.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>)))}
|
||||
</motion.div>)}
|
||||
</AnimatePresence>
|
||||
@@ -649,7 +841,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
||||
((openItemIdx < 0)
|
||||
&& 'top-nav-mobile-active font-bold'))}>
|
||||
その他 »
|
||||
<TranslatedText messageKey="more"/>
|
||||
</PrefetchLink>
|
||||
<TopNavUser user={user} sp/>
|
||||
<Separator/>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする