設定画面 (#34) #397
+3
-15
@@ -13,13 +13,11 @@ import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
|||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { apiPost, isApiError } from '@/lib/api'
|
import { apiPost, isApiError } from '@/lib/api'
|
||||||
import { applyClientAppearance,
|
import { applyClientAppearance,
|
||||||
CLIENT_SETTINGS_EVENT,
|
|
||||||
fetchUserSettings,
|
fetchUserSettings,
|
||||||
getClientAnimationMode,
|
|
||||||
getClientThemeMode,
|
getClientThemeMode,
|
||||||
seedClientThemeMode } from '@/lib/settings'
|
seedClientThemeMode } from '@/lib/settings'
|
||||||
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
|
|
||||||
import type { ClientAnimationMode } from '@/lib/settings'
|
|
||||||
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
||||||
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
||||||
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
||||||
@@ -112,8 +110,8 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
|
|||||||
const App: FC = () => {
|
const App: FC = () => {
|
||||||
const [user, setUser] = useState<User | null> (null)
|
const [user, setUser] = useState<User | null> (null)
|
||||||
const [status, setStatus] = useState (200)
|
const [status, setStatus] = useState (200)
|
||||||
const [animationMode, setAnimationMode] =
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
useState<ClientAnimationMode> (() => getClientAnimationMode ())
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
|
|
||||||
const appLayoutTransition = useMemo (
|
const appLayoutTransition = useMemo (
|
||||||
() => (
|
() => (
|
||||||
@@ -139,16 +137,6 @@ const App: FC = () => {
|
|||||||
return () => mediaQuery.removeEventListener ('change', handleThemeChange)
|
return () => mediaQuery.removeEventListener ('change', handleThemeChange)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
const handleClientSettingsChange = () => {
|
|
||||||
setAnimationMode (getClientAnimationMode ())
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const createUser = async () => {
|
const createUser = async () => {
|
||||||
const data = await apiPost<{ code: string; user: User }> ('/users')
|
const data = await apiPost<{ code: string; user: User }> ('/users')
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import NicoViewer from '@/components/NicoViewer'
|
|||||||
import TwitterEmbed from '@/components/TwitterEmbed'
|
import TwitterEmbed from '@/components/TwitterEmbed'
|
||||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { getClientEmbedAutoLoadMode } from '@/lib/settings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
|
|
||||||
import type { FC, RefObject } from 'react'
|
import type { FC, RefObject } from 'react'
|
||||||
|
|
||||||
@@ -36,7 +36,8 @@ const PostEmbed: FC<Props> = (
|
|||||||
onError },
|
onError },
|
||||||
) => {
|
) => {
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
const embedAutoLoadMode = getClientEmbedAutoLoadMode ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
|
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
|
||||||
const [manualLoadRequested, setManualLoadRequested] = useState (false)
|
const [manualLoadRequested, setManualLoadRequested] = useState (false)
|
||||||
const [framed, setFramed] = useState (false)
|
const [framed, setFramed] = useState (false)
|
||||||
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
|
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useRef } from 'react'
|
|||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import { getClientThumbnailMode } from '@/lib/settings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
|
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
|
||||||
|
|
||||||
@@ -17,7 +17,15 @@ type Props = { posts: Post[]
|
|||||||
|
|
||||||
const PostList: FC<Props> = ({ posts, onClick }) => {
|
const PostList: FC<Props> = ({ posts, onClick }) => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const thumbnailMode = getClientThumbnailMode ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
|
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
|
||||||
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
|
const cardLayoutTransition =
|
||||||
|
animationMode === 'off'
|
||||||
|
? { duration: 0 }
|
||||||
|
: animationMode === 'reduced'
|
||||||
|
? { duration: .08, ease: 'linear' as const }
|
||||||
|
: { duration: .2, ease: 'easeOut' as const }
|
||||||
|
|
||||||
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
|
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
|
||||||
|
|
||||||
@@ -41,7 +49,8 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
|
|||||||
}}>
|
}}>
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
layoutId={layoutId}
|
layout={animationMode === 'off' ? false : true}
|
||||||
|
layoutId={animationMode === 'off' ? undefined : layoutId}
|
||||||
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
|
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
|
||||||
'transform-gpu will-change-transform',
|
'transform-gpu will-change-transform',
|
||||||
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
|
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
|
||||||
@@ -61,7 +70,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
|
|||||||
cardRef.current.style.zIndex = ''
|
cardRef.current.style.zIndex = ''
|
||||||
cardRef.current.style.position = ''
|
cardRef.current.style.position = ''
|
||||||
}}
|
}}
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
|
transition={{ layout: cardLayoutTransition }}>
|
||||||
{thumbnailMode === 'off'
|
{thumbnailMode === 'off'
|
||||||
? (
|
? (
|
||||||
<div className="flex h-full w-full items-center justify-center
|
<div className="flex h-full w-full items-center justify-center
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import PrefetchLink from '@/components/PrefetchLink'
|
|||||||
import TopNavUser from '@/components/TopNavUser'
|
import TopNavUser from '@/components/TopNavUser'
|
||||||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||||||
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||||
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { fetchTag, fetchTagByName } from '@/lib/tags'
|
import { fetchTag, fetchTagByName } from '@/lib/tags'
|
||||||
import { fetchMaterial } from '@/lib/materials'
|
import { fetchMaterial } from '@/lib/materials'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -91,6 +92,10 @@ export const menuOutline = (
|
|||||||
|
|
||||||
const TopNav: FC<Props> = ({ user }) => {
|
const TopNav: FC<Props> = ({ user }) => {
|
||||||
const location = useLocation ()
|
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 dirRef = useRef<(-1) | 1> (1)
|
||||||
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
||||||
@@ -155,6 +160,20 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
const activeIdx =
|
const activeIdx =
|
||||||
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
||||||
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
|
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
|
||||||
|
const topNavTransition =
|
||||||
|
reducedAnimations
|
||||||
|
? { duration: .08, ease: 'linear' as const }
|
||||||
|
: { duration: .2, ease: 'easeOut' as const }
|
||||||
|
const opacityTransition =
|
||||||
|
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<number> (activeIdx)
|
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||||
|
|
||||||
@@ -206,7 +225,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
<div aria-hidden
|
<div aria-hidden
|
||||||
className={cn ('absolute inset-y-0 h-12',
|
className={cn ('absolute inset-y-0 h-12',
|
||||||
'bg-yellow-200 dark:bg-red-950',
|
'bg-yellow-200 dark:bg-red-950',
|
||||||
'transition-[transform,width] duration-200 ease-out')}
|
highlightTransitionClass)}
|
||||||
style={{ width: hl.width,
|
style={{ width: hl.width,
|
||||||
transform: `translateX(${ hl.left }px)`,
|
transform: `translateX(${ hl.left }px)`,
|
||||||
opacity: hl.visible ? 1 : 0 }}/>
|
opacity: hl.visible ? 1 : 0 }}/>
|
||||||
@@ -214,10 +233,12 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
{visibleMenu.map ((item, i) => (
|
{visibleMenu.map ((item, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={item.to}
|
key={item.to}
|
||||||
layoutId={`menu-${ item.name }`}
|
{...(animationsOff
|
||||||
|
? { }
|
||||||
|
: { layoutId: `menu-${ item.name }` })}
|
||||||
animate={{ opacity: moreVsbl ? 0 : 1 }}
|
animate={{ opacity: moreVsbl ? 0 : 1 }}
|
||||||
transition={{ opacity: { duration: .12 },
|
transition={{ opacity: opacityTransition,
|
||||||
layout: { duration: .2, ease: 'easeOut' } }}
|
layout: topNavTransition }}
|
||||||
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
|
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
|
||||||
onMouseEnter={() => setMoreVsbl (false)}>
|
onMouseEnter={() => setMoreVsbl (false)}>
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
@@ -264,7 +285,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key="submenu-shell"
|
key="submenu-shell"
|
||||||
layout
|
{...(animationsOff ? { }
|
||||||
|
: { layout: true })}
|
||||||
className="relative z-20 hidden md:block overflow-hidden
|
className="relative z-20 hidden md:block overflow-hidden
|
||||||
bg-yellow-200 dark:bg-red-950"
|
bg-yellow-200 dark:bg-red-950"
|
||||||
animate={{ height: submenuHeight }}
|
animate={{ height: submenuHeight }}
|
||||||
@@ -272,7 +294,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
if (moreVsbl)
|
if (moreVsbl)
|
||||||
setMoreVsbl (false)
|
setMoreVsbl (false)
|
||||||
}}
|
}}
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: topNavTransition }}
|
||||||
onAnimationComplete={() => {
|
onAnimationComplete={() => {
|
||||||
measure (moreVsbl ? -1 : activeIdx)
|
measure (moreVsbl ? -1 : activeIdx)
|
||||||
}}>
|
}}>
|
||||||
@@ -282,12 +304,14 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
<div key={i} className="relative h-[40px]">
|
<div key={i} className="relative h-[40px]">
|
||||||
<div className="absolute inset-0 flex items-center px-3">
|
<div className="absolute inset-0 flex items-center px-3">
|
||||||
<motion.div
|
<motion.div
|
||||||
transition={{ duration: .2, ease: 'easeOut' }}
|
transition={topNavTransition}
|
||||||
{...((item.visible ?? true)
|
{...(animationsOff
|
||||||
|
? { }
|
||||||
|
: ((item.visible ?? true)
|
||||||
? { layoutId: `menu-${ item.name }` }
|
? { layoutId: `menu-${ item.name }` }
|
||||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||||
animate: { x: 0, y: 0, opacity: 1 },
|
animate: { x: 0, y: 0, opacity: 1 },
|
||||||
exit: { x: 40, y: -40, opacity: 0 } })}
|
exit: { x: 40, y: -40, opacity: 0 } }))}
|
||||||
className="z-10 h-full flex items-center px-3 font-bold w-28">
|
className="z-10 h-full flex items-center px-3 font-bold w-28">
|
||||||
<h2>{item.name}</h2>
|
<h2>{item.name}</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -298,25 +322,29 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
? (
|
? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`c-${ i }-${ j }`}
|
key={`c-${ i }-${ j }`}
|
||||||
transition={{ duration: .2, ease: 'easeOut' }}
|
transition={topNavTransition}
|
||||||
{...((visibleMenu[activeIdx]?.name
|
{...(animationsOff
|
||||||
|
? { }
|
||||||
|
: ((visibleMenu[activeIdx]?.name
|
||||||
=== item.name)
|
=== item.name)
|
||||||
? { layoutId: `submenu-${ item.name }-${ j }` }
|
? { layoutId: `submenu-${ item.name }-${ j }` }
|
||||||
: { initial: { y: -40, opacity: 0 },
|
: { initial: { y: -40, opacity: 0 },
|
||||||
animate: { y: 0, opacity: 1 },
|
animate: { y: 0, opacity: 1 },
|
||||||
exit: { y: -40, opacity: 0 } })}>
|
exit: { y: -40, opacity: 0 } }))}>
|
||||||
{subItem.component}
|
{subItem.component}
|
||||||
</motion.div>)
|
</motion.div>)
|
||||||
: (
|
: (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`l-${ i }-${ j }`}
|
key={`l-${ i }-${ j }`}
|
||||||
transition={{ duration: .2, ease: 'easeOut' }}
|
transition={topNavTransition}
|
||||||
{...((visibleMenu[activeIdx]?.name
|
{...(animationsOff
|
||||||
|
? { }
|
||||||
|
: ((visibleMenu[activeIdx]?.name
|
||||||
=== item.name)
|
=== item.name)
|
||||||
? { layoutId: `submenu-${ item.name }-${ j }` }
|
? { layoutId: `submenu-${ item.name }-${ j }` }
|
||||||
: { initial: { y: -40, opacity: 0 },
|
: { initial: { y: -40, opacity: 0 },
|
||||||
animate: { y: 0, opacity: 1 },
|
animate: { y: 0, opacity: 1 },
|
||||||
exit: { y: -40, opacity: 0 } })}>
|
exit: { y: -40, opacity: 0 } }))}>
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={subItem.to}
|
to={subItem.to}
|
||||||
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||||
@@ -330,6 +358,30 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
|
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
|
||||||
&& (
|
&& (
|
||||||
<div className="relative h-[40px]">
|
<div className="relative h-[40px]">
|
||||||
|
{animationsOff
|
||||||
|
? (
|
||||||
|
<div className="absolute inset-0 flex items-center px-3">
|
||||||
|
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||||
|
.filter (item => item.visible ?? true)
|
||||||
|
.map ((item, i) => (
|
||||||
|
'component' in item
|
||||||
|
? (
|
||||||
|
<div key={`c-${ i }`}>
|
||||||
|
{item.component}
|
||||||
|
</div>)
|
||||||
|
: (
|
||||||
|
<div key={`l-${ i }`}>
|
||||||
|
<PrefetchLink
|
||||||
|
to={item.to}
|
||||||
|
target={item.to.slice (0, 2) === '//'
|
||||||
|
? '_blank'
|
||||||
|
: undefined}
|
||||||
|
className="h-full flex items-center px-3">
|
||||||
|
{item.name}
|
||||||
|
</PrefetchLink>
|
||||||
|
</div>)))}
|
||||||
|
</div>)
|
||||||
|
: (
|
||||||
<AnimatePresence initial={false} custom={dir}>
|
<AnimatePresence initial={false} custom={dir}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={activeIdx}
|
key={activeIdx}
|
||||||
@@ -341,7 +393,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
initial="enter"
|
initial="enter"
|
||||||
animate="centre"
|
animate="centre"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
transition={topNavTransition}>
|
||||||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||||
.filter (item => item.visible ?? true)
|
.filter (item => item.visible ?? true)
|
||||||
.map ((item, i) => (
|
.map ((item, i) => (
|
||||||
@@ -349,28 +401,90 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
? (
|
? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`c-${ i }`}
|
key={`c-${ i }`}
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: topNavTransition }}
|
||||||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||||||
{item.component}
|
{item.component}
|
||||||
</motion.div>)
|
</motion.div>)
|
||||||
: (
|
: (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`l-${ i }`}
|
key={`l-${ i }`}
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: topNavTransition }}
|
||||||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={item.to}
|
to={item.to}
|
||||||
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
target={item.to.slice (0, 2) === '//'
|
||||||
|
? '_blank'
|
||||||
|
: undefined}
|
||||||
className="h-full flex items-center px-3">
|
className="h-full flex items-center px-3">
|
||||||
{item.name}
|
{item.name}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</motion.div>)))}
|
</motion.div>)))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>)}
|
||||||
</div>))}
|
</div>))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{animationsOff
|
||||||
|
? (
|
||||||
|
menuOpen && (
|
||||||
|
<div
|
||||||
|
className={cn ('flex flex-col md:hidden',
|
||||||
|
'bg-yellow-200 dark:bg-red-975 items-start')}>
|
||||||
|
<Separator/>
|
||||||
|
{visibleMenu.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>
|
||||||
|
|
||||||
|
{i === openItemIdx && (
|
||||||
|
<div className="w-full bg-yellow-50 dark:bg-red-950">
|
||||||
|
{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}
|
||||||
|
target={subItem.to.slice (0, 2) === '//'
|
||||||
|
? '_blank'
|
||||||
|
: undefined}
|
||||||
|
className="w-full min-h-[36px] flex items-center pl-12">
|
||||||
|
{subItem.name}
|
||||||
|
</PrefetchLink>)))}
|
||||||
|
</div>)}
|
||||||
|
</Fragment>))}
|
||||||
|
<PrefetchLink
|
||||||
|
to="/more"
|
||||||
|
ref={(el: (HTMLAnchorElement | null)) => {
|
||||||
|
itemsRef.current[visibleMenu.length] = el
|
||||||
|
}}
|
||||||
|
className={cn ('w-full min-h-[40px] flex items-center pl-8',
|
||||||
|
((openItemIdx < 0)
|
||||||
|
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
|
||||||
|
その他 »
|
||||||
|
</PrefetchLink>
|
||||||
|
<TopNavUser user={user} sp/>
|
||||||
|
<Separator/>
|
||||||
|
</div>))
|
||||||
|
: (
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -384,7 +498,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
initial="closed"
|
initial="closed"
|
||||||
animate="open"
|
animate="open"
|
||||||
exit="closed"
|
exit="closed"
|
||||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
transition={topNavTransition}>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
{visibleMenu.map ((item, i) => (
|
{visibleMenu.map ((item, i) => (
|
||||||
<Fragment key={i}>
|
<Fragment key={i}>
|
||||||
@@ -417,7 +531,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
initial="closed"
|
initial="closed"
|
||||||
animate="open"
|
animate="open"
|
||||||
exit="closed"
|
exit="closed"
|
||||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
transition={topNavTransition}>
|
||||||
{item.subMenu
|
{item.subMenu
|
||||||
.filter (subItem => subItem.visible ?? true)
|
.filter (subItem => subItem.visible ?? true)
|
||||||
.map ((subItem, j) => (
|
.map ((subItem, j) => (
|
||||||
@@ -452,7 +566,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
<TopNavUser user={user} sp/>
|
<TopNavUser user={user} sp/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
</motion.div>)}
|
</motion.div>)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>)}
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { getClientBehaviorSettings,
|
import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings'
|
||||||
setClientBehaviorSettings } from '@/lib/settings'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type {
|
import type { FC, ReactNode } from 'react'
|
||||||
ClientAnimationMode,
|
|
||||||
|
import type { ClientAnimationMode,
|
||||||
ClientBehaviorSettings,
|
ClientBehaviorSettings,
|
||||||
ClientEmbedAutoLoadMode,
|
ClientEmbedAutoLoadMode,
|
||||||
ClientThumbnailMode } from '@/lib/settings'
|
ClientThumbnailMode } from '@/lib/settings'
|
||||||
import type { FC, ReactNode } from 'react'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sectionClassName: string }
|
sectionClassName: string }
|
||||||
@@ -31,22 +30,19 @@ type SettingBlockProps = {
|
|||||||
children: ReactNode }
|
children: ReactNode }
|
||||||
|
|
||||||
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
|
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
|
||||||
{ value: 'normal', label: '通常' },
|
|
||||||
{ value: 'reduced', label: '控えめ' },
|
|
||||||
{ value: 'off', label: 'なし' },
|
{ value: 'off', label: 'なし' },
|
||||||
]
|
{ value: 'reduced', label: '控えめ' },
|
||||||
|
{ value: 'normal', label: '通常' }]
|
||||||
|
|
||||||
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
|
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
|
||||||
{ value: 'auto', label: '自動' },
|
|
||||||
{ value: 'manual', label: 'クリック時' },
|
|
||||||
{ value: 'off', label: '読み込まない' },
|
{ value: 'off', label: '読み込まない' },
|
||||||
]
|
{ value: 'manual', label: 'クリック時' },
|
||||||
|
{ value: 'auto', label: '自動' }]
|
||||||
|
|
||||||
const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [
|
const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [
|
||||||
{ value: 'normal', label: '通常' },
|
|
||||||
{ value: 'light', label: '軽量' },
|
|
||||||
{ value: 'off', label: '非表示' },
|
{ value: 'off', label: '非表示' },
|
||||||
]
|
{ value: 'light', label: '軽量' },
|
||||||
|
{ value: 'normal', label: '通常' }]
|
||||||
|
|
||||||
|
|
||||||
const SegmentedControl = <T extends string,> (
|
const SegmentedControl = <T extends string,> (
|
||||||
@@ -103,8 +99,7 @@ const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => {
|
|||||||
|
|
||||||
const hasUnsavedChanges = useMemo (
|
const hasUnsavedChanges = useMemo (
|
||||||
() => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings),
|
() => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings),
|
||||||
[draftSettings, savedSettings],
|
[draftSettings, savedSettings])
|
||||||
)
|
|
||||||
|
|
||||||
const updateDraft = <Key extends keyof ClientBehaviorSettings,> (
|
const updateDraft = <Key extends keyof ClientBehaviorSettings,> (
|
||||||
key: Key,
|
key: Key,
|
||||||
@@ -129,12 +124,6 @@ const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => {
|
|||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-xl font-bold">動作</h2>
|
<h2 className="text-xl font-bold">動作</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
この設定は現在のブラウザに保存されます。
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
重い端末や通信量を抑えたい場合に調整します。
|
|
||||||
</p>
|
|
||||||
{hasUnsavedChanges && (
|
{hasUnsavedChanges && (
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
未保存の変更があります
|
未保存の変更があります
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_SETTINGS_EVENT,
|
||||||
|
getClientBehaviorSettings,
|
||||||
|
} from '@/lib/settings'
|
||||||
|
|
||||||
|
import type { ClientBehaviorSettings } from '@/lib/settings'
|
||||||
|
|
||||||
|
|
||||||
|
export const useClientBehaviourSettings = (): ClientBehaviorSettings => {
|
||||||
|
const [settings, setSettings] =
|
||||||
|
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
const handleClientSettingsChange = () => {
|
||||||
|
setSettings (getClientBehaviorSettings ())
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return settings
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import { SITE_TITLE } from '@/config'
|
|||||||
import { isApiError } from '@/lib/api'
|
import { isApiError } from '@/lib/api'
|
||||||
import { fetchPost, toggleViewedFlg } from '@/lib/posts'
|
import { fetchPost, toggleViewedFlg } from '@/lib/posts'
|
||||||
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
||||||
import { getClientThumbnailMode } from '@/lib/settings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
@@ -30,8 +30,17 @@ type Props = { user: User | null }
|
|||||||
|
|
||||||
const PostDetailPage: FC<Props> = ({ user }) => {
|
const PostDetailPage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
const thumbnailMode = getClientThumbnailMode ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
|
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
|
||||||
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const { id } = useParams ()
|
const { id } = useParams ()
|
||||||
|
const heroLayoutId = animationMode === 'off' ? undefined : `page-${ id }`
|
||||||
|
const heroTransition =
|
||||||
|
animationMode === 'off'
|
||||||
|
? { duration: 0 }
|
||||||
|
: animationMode === 'reduced'
|
||||||
|
? { duration: .08, ease: 'linear' as const }
|
||||||
|
: { duration: .2, ease: 'easeOut' as const }
|
||||||
const postId = String (id ?? '')
|
const postId = String (id ?? '')
|
||||||
const postKey = postsKeys.show (postId)
|
const postKey = postsKeys.show (postId)
|
||||||
|
|
||||||
@@ -138,12 +147,12 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
{thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && (
|
{thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId={`page-${ id }`}
|
layoutId={heroLayoutId}
|
||||||
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px]
|
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px]
|
||||||
overflow-hidden rounded-xl pointer-events-none z-50"
|
overflow-hidden rounded-xl pointer-events-none z-50"
|
||||||
initial={{ opacity: 1 }}
|
initial={{ opacity: 1 }}
|
||||||
animate={{ opacity: 0 }}
|
animate={{ opacity: 0 }}
|
||||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
transition={heroTransition}>
|
||||||
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
||||||
alt={post.title || post.url}
|
alt={post.title || post.url}
|
||||||
title={post.title || post.url || undefined}
|
title={post.title || post.url || undefined}
|
||||||
|
|||||||
@@ -319,23 +319,6 @@ const ThemeSection: FC<SharedSectionProps> = (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-border bg-background p-3
|
|
||||||
text-foreground shadow-sm transition-colors">
|
|
||||||
<p className="text-sm font-medium">プレビュー</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
現在の表示テーマ: {themeLabelById (draftActiveThemeId, draftCustomThemes)}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-3" style={themePreviewStyle (draftTagColours)}>
|
|
||||||
{CATEGORIES.map (category => (
|
|
||||||
<TagLink
|
|
||||||
key={category}
|
|
||||||
tag={previewTags[category]}
|
|
||||||
linkFlg={false}
|
|
||||||
truncateOnMobile
|
|
||||||
withWiki={false}/>))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="button" onClick={onThemeSubmit}>
|
<Button type="button" onClick={onThemeSubmit}>
|
||||||
テーマを保存
|
テーマを保存
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする