設定画面 (#34) (#397)

Reviewed-on: #397
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #397 でマージされました.
このコミットが含まれているのは:
2026-07-07 00:48:41 +09:00
committed by みてるぞ
コミット f5b632ed89
71個のファイルの変更7785行の追加584行の削除
+138 -13
ファイルの表示
@@ -1,5 +1,5 @@
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { BrowserRouter,
Navigate,
Route,
@@ -12,6 +12,20 @@ import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import { applyClientAnimationMode,
applyClientAppearance,
type ClientAnimationMode,
fetchUserThemeSlots,
fetchUserSettings,
getClientThemeMode,
hasStoredClientThemeSelection,
normaliseUserThemeSlots,
setCachedUserThemeSlots,
seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
@@ -48,11 +62,51 @@ import type { Dispatch, FC, SetStateAction } from 'react'
import type { User } from '@/types'
const RouteTransitionWrapper = ({ user, setUser }: {
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation ()
if (animationMode === 'off')
{
return (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
}
return (
<AnimatePresence mode="wait">
<Routes location={location}>
@@ -103,6 +157,37 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
const App: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
const appLayoutTransition = useMemo (
() => (
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
),
[animationMode],
)
useEffect (() => {
applyClientAppearance ()
const mediaQuery = window.matchMedia ('(prefers-color-scheme: dark)')
const handleThemeChange = () => {
if (getClientThemeMode () === 'system')
applyClientAppearance ()
}
mediaQuery.addEventListener ('change', handleThemeChange)
return () => mediaQuery.removeEventListener ('change', handleThemeChange)
}, [])
useEffect (() => {
applyClientAnimationMode (animationMode)
}, [animationMode])
useEffect (() => {
const createUser = async () => {
@@ -137,6 +222,30 @@ const App: FC = () => {
createUser ()
}, [])
useEffect (() => {
if (!(user))
return
void (async () => {
try
{
const [settings, themeSlots] = await Promise.all ([
fetchUserSettings (),
fetchUserThemeSlots (),
])
const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
setCachedUserThemeSlots (storedThemeSlots)
if (!(hasStoredClientThemeSelection ()))
seedClientThemeMode (settings.theme)
applyClientAppearance ()
}
catch
{
return
}
}) ()
}, [user])
switch (status)
{
case 503:
@@ -150,17 +259,33 @@ const App: FC = () => {
<BrowserRouter>
<DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</MotionConfig>
<Toaster/>
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</BrowserRouter>
</>)
+12 -2
ファイルの表示
@@ -4,6 +4,8 @@ import { motion } from 'framer-motion'
import { useRef } from 'react'
import TagLink from '@/components/TagLink'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { CSSProperties, FC, MutableRefObject } from 'react'
@@ -20,6 +22,12 @@ type Props = {
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const dndId = `tag-node:${ pathKey }`
const downPosRef = useRef<{ x: number; y: number } | null> (null)
@@ -94,8 +102,10 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
{...listeners}>
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
transition={{ layout: layoutTransition }}
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>
</div>)
+61 -10
ファイルの表示
@@ -4,6 +4,8 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import type { FC, RefObject } from 'react'
@@ -24,16 +26,19 @@ type Props = {
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const PostEmbed: FC<Props> = (
{ ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError },
) => {
const dialogue = useDialogue ()
const behaviourSettings = useClientBehaviourSettings ()
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
const [manualLoadRequested, setManualLoadRequested] = useState (false)
const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false)
@@ -105,6 +110,10 @@ const PostEmbed: FC<Props> = ({
niconicoVideoReadyRef.current = false
}, [post.url])
useEffect (() => {
setManualLoadRequested (false)
}, [embedAutoLoadMode, post.url])
useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange))
return
@@ -117,6 +126,28 @@ const PostEmbed: FC<Props> = ({
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url)
const shouldLoadEmbed =
embedAutoLoadMode === 'auto'
|| (
embedAutoLoadMode === 'manual'
&& manualLoadRequested
)
const externalLink = (
<a href={post.url} target="_blank" rel="noreferrer">
</a>)
const manualLoadControl = (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => setManualLoadRequested (true)}>
</Button>
{externalLink}
</div>)
switch (url.hostname.split ('.').slice (-2).join ('.'))
{
@@ -128,6 +159,11 @@ const PostEmbed: FC<Props> = ({
const [videoId] = mVideoId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return (
<NicoViewer
ref={ref}
@@ -150,6 +186,11 @@ const PostEmbed: FC<Props> = ({
const [userId] = mUserId
const [statusId] = mStatusId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return <TwitterEmbed userId={userId} statusId={statusId}/>
}
@@ -159,6 +200,11 @@ const PostEmbed: FC<Props> = ({
if (!(videoId))
break
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return (
<YoutubeEmbed videoId={videoId} opts={{ playerVars: {
playsinline: 1,
@@ -184,6 +230,11 @@ const PostEmbed: FC<Props> = ({
height={360}/>)
: (
<div>
{embedAutoLoadMode === 'off'
? externalLink
: embedAutoLoadMode === 'manual' && shouldLoadEmbed === false
? manualLoadControl
: (
<a href="#" onClick={async e => {
e.preventDefault ()
@@ -197,7 +248,7 @@ const PostEmbed: FC<Props> = ({
confirmText: '表示' }))
}}>
</a>
</a>)}
</div>)}
</>)
}
+6 -3
ファイルの表示
@@ -68,9 +68,12 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token, nico: '0' } })
setSuggestions (data.filter (t => t.postCount > 0))
setSuggestionsVsbl (suggestions.length > 0)
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q: token,
nico: '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
}
return (
+35 -10
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
@@ -16,6 +17,15 @@ type Props = { posts: Post[]
const PostList: FC<Props> = ({ posts, onClick }) => {
const location = useLocation ()
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)
@@ -39,7 +49,8 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
}}>
<motion.div
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',
'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
@@ -54,22 +65,36 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
}}
onLayoutAnimationComplete={() => {
if (!(cardRef.current))
return
return
cardRef.current.style.zIndex = ''
cardRef.current.style.position = ''
}}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={i < 12 ? 'eager' : 'lazy'}
decoding="async"
className="object-cover w-full h-full"/>
transition={{ layout: cardLayoutTransition }}>
{thumbnailMode === 'off'
? (
<div className="flex h-full w-full items-center justify-center
bg-muted text-center text-xs text-muted-foreground">
</div>)
: (
<img
src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={
thumbnailMode === 'light'
? 'lazy'
: i < 12
? 'eager'
: 'lazy'
}
decoding="async"
className="object-cover w-full h-full"/>)}
</motion.div>
</PrefetchLink>)
})}
</div>)
}
export default PostList
export default PostList
+27 -4
ファイルの表示
@@ -5,6 +5,8 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -33,10 +35,20 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
[to],
)
const url = useMemo (() => {
const path = (typeof to === 'string') ? to : createPath (to)
return (new URL (path, location.origin)).toString ()
}, [to])
return (new URL (path, window.location.origin)).toString ()
}, [path])
const nextPathname = useMemo (
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => {
@@ -54,11 +66,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => {
onMouseEnter?.(ev)
if (ev.defaultPrevented || linkPreloadMode !== 'intent')
return
await doPrefetch ()
}
const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => {
onTouchStart?.(ev)
if (ev.defaultPrevented || linkPreloadMode !== 'intent')
return
await doPrefetch ()
}
@@ -77,6 +93,13 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => {
setOverlay (true)
})
@@ -99,7 +122,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
return (
<a ref={ref}
href={typeof to === 'string' ? to : createPath (to)}
href={path}
onMouseEnter={handleMouseEnter}
onTouchStart={handleTouchStart}
onClick={handleClick}
+5 -1
ファイルの表示
@@ -1,3 +1,4 @@
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useEffect, useRef, useState } from 'react'
@@ -20,6 +21,8 @@ const MIN_MARQUEE_OVERFLOW_PX = 1
const ResponsiveMarqueeText: FC<Props> = (
{ text, className, truncateOnMobile = false, title },
) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const outerRef = useRef<HTMLSpanElement | null> (null)
const staticRef = useRef<HTMLSpanElement | null> (null)
const animatedRef = useRef<HTMLSpanElement | null> (null)
@@ -98,6 +101,7 @@ const ResponsiveMarqueeText: FC<Props> = (
const animated = animatedRef.current
const canMarquee = (
active
&& animationMode !== 'off'
&& desktopMarqueeEnabled
&& overflowPx >= MIN_MARQUEE_OVERFLOW_PX
&& animated != null)
@@ -207,7 +211,7 @@ const ResponsiveMarqueeText: FC<Props> = (
resetAnimated ()
setMarqueeVisible (false)
}
}, [active, desktopMarqueeEnabled, overflowPx, text])
}, [active, animationMode, desktopMarqueeEnabled, overflowPx, text])
return (
<span
+93 -18
ファイルの表示
@@ -20,7 +20,9 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core'
@@ -60,7 +62,6 @@ const renderTagTree = (
?? [])]
}
const isDescendant = (
root: TagWithSections,
targetId: number,
@@ -127,6 +128,37 @@ const buildTagByCategory = (post: Post): TagByCategory => {
}
const buildFlatTagByCategory = (
byCategory: TagByCategory,
): TagByCategory => {
const tagsTmp = { } as TagByCategory
const seen = new Set<number> ()
for (const category of CATEGORIES)
tagsTmp[category] = []
const visit = (tag: TagWithSections) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
tagsTmp[tag.category].push ({ ...tag, children: [] })
for (const child of tag.children ?? [])
visit (child)
}
for (const category of CATEGORIES)
for (const tag of byCategory[category] ?? [])
visit (tag)
for (const category of CATEGORIES)
tagsTmp[category].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1)
return tagsTmp
}
const changeCategory = async (
tagId: number,
category: Category): Promise<void> => {
@@ -156,6 +188,13 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
sp = Boolean (sp)
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped'
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const baseTags = useMemo<TagByCategory> (() => {
const tagsTmp = { } as TagByCategory
@@ -177,6 +216,10 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
const [dragging, setDragging] = useState (false)
const [saving, setSaving] = useState (false)
const [tags, setTags] = useState (baseTags)
const flatTagsByCategory = useMemo<TagByCategory> (
() => buildFlatTagByCategory (tags),
[tags],
)
const suppressClickRef = useRef (false)
@@ -310,26 +353,58 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
document.body.style.userSelect = ''
}}
modifiers={[restrictToWindowEdges]}>
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
<div className="my-3" key={cat}>
<SubsectionTitle>
<motion.div
layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
{CATEGORY_NAMES[cat]}
</motion.div>
</SubsectionTitle>
{CATEGORIES.map ((cat: Category) => {
const categoryTags =
tagRelationDisplay === 'grouped'
? (tags[cat] ?? [])
: (flatTagsByCategory[cat] ?? [])
<ul>
{(tags[cat] ?? []).flatMap (tag => (
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))}
<DropSlot cat={cat}/>
</ul>
</div>))}
if (!(categoryTags.length > 0 || dragging))
return null
return (
<div className="my-3" key={cat}>
<SubsectionTitle>
<motion.div
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: layoutTransition }}>
{CATEGORY_NAMES[cat]}
</motion.div>
</SubsectionTitle>
<ul>
{(tagRelationDisplay === 'grouped'
? (tags[cat] ?? []).flatMap (tag =>
renderTagTree (
tag,
0,
`cat-${ cat }`,
suppressClickRef,
undefined,
sp,
),
)
: (flatTagsByCategory[cat] ?? []).map (tag => (
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
<DraggableDroppableTagRow
tag={tag}
nestLevel={0}
pathKey={`flat-${ cat }-${ tag.id }`}
suppressClickRef={suppressClickRef}
sp={sp}/>
</li>)))}
<DropSlot cat={cat}/>
</ul>
</div>)
})}
{post && (
<motion.div
layoutId={`post-info-${ sp }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
layoutId={animationMode === 'off'
? undefined
: `post-info-${ sp }`}
transition={{ layout: layoutTransition }}>
<SectionTitle></SectionTitle>
<ul>
<li>Id.: {post.id}</li>
+18 -12
ファイルの表示
@@ -1,9 +1,8 @@
import PrefetchLink from '@/components/PrefetchLink'
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { cn } from '@/lib/utils'
import type { ComponentProps, FC, HTMLAttributes } from 'react'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types'
@@ -36,15 +35,16 @@ const TagLink: FC<Props> = ({ tag,
withWiki = true,
withCount = true,
className,
style,
title,
...props }) => {
const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
const linkClass = cn (
spanClass,
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
const colourStyle = {
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`,
...style,
} as CSSProperties
const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
const rootClass =
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
@@ -63,7 +63,8 @@ const TagLink: FC<Props> = ({ tag,
? (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)
: (
@@ -71,13 +72,15 @@ const TagLink: FC<Props> = ({ tag,
? (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)))
: (
@@ -120,6 +123,7 @@ const TagLink: FC<Props> = ({ tag,
<span
title={textTitle}
className={cn (spanClass, textClass, className)}
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.matchedAlias}
@@ -134,6 +138,7 @@ const TagLink: FC<Props> = ({ tag,
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
title={textTitle}
className={cn (linkClass, textClass, className)}
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.name}
@@ -144,6 +149,7 @@ const TagLink: FC<Props> = ({ tag,
<span
title={textTitle}
className={cn (spanClass, textClass, className)}
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.name}
+1
ファイルの表示
@@ -105,6 +105,7 @@ const TagSearch: FC = () => {
return (
<div className="relative w-full">
<input type="text"
data-shortcut-focus-search="true"
placeholder="タグ検索..."
value={search}
onChange={whenChanged}
+40 -18
ファイルの表示
@@ -8,6 +8,8 @@ import SectionTitle from '@/components/common/SectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { CATEGORIES } from '@/consts'
import { apiGet } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import type { FC, MouseEvent } from 'react'
@@ -21,6 +23,13 @@ type Props = { posts: Post[]
const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const navigate = useNavigate ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const [tagsVsbl, setTagsVsbl] = useState (false)
const [tags, setTags] = useState<TagByCategory> ({ })
@@ -65,12 +74,18 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1 min-w-0 max-w-full overflow-hidden">
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>
{animationsOff
? (
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
<TagLink tag={tag} onClick={onClick}/>
</div>)
: (
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: layoutTransition }}
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>)}
</li>))) : [])}
</ul>
<SectionTitle></SectionTitle>
@@ -103,18 +118,25 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
{posts.length > 0 && TagBlock}
</div>
<AnimatePresence initial={false}>
{tagsVsbl && (
<motion.div
key="sptags"
className="md:hidden overflow-hidden"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: .2, ease: 'easeOut' }}>
{posts.length > 0 && TagBlock}
</motion.div>)}
</AnimatePresence>
{animationsOff
? (
tagsVsbl && (
<div className="md:hidden overflow-hidden">
{posts.length > 0 && TagBlock}
</div>))
: (
<AnimatePresence initial={false}>
{tagsVsbl && (
<motion.div
key="sptags"
className="md:hidden overflow-hidden"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={layoutTransition}>
{posts.length > 0 && TagBlock}
</motion.div>)}
</AnimatePresence>)}
<a href="#"
className="md:hidden block my-2 text-center text-sm
+263 -150
ファイルの表示
@@ -8,6 +8,7 @@ import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import { fetchMaterial } from '@/lib/materials'
import { cn } from '@/lib/utils'
@@ -91,6 +92,10 @@ export const menuOutline = (
const TopNav: FC<Props> = ({ user }) => {
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)[]> ([])
@@ -155,6 +160,24 @@ const TopNav: FC<Props> = ({ user }) => {
const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
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<number> (activeIdx)
@@ -189,13 +212,11 @@ const TopNav: FC<Props> = ({ user }) => {
return (
<>
<nav className="px-3 flex justify-between items-center w-full
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<nav className="top-nav-root px-3 flex justify-between items-center w-full">
<div className="flex items-center gap-2 h-12">
<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"
className="top-nav-brand-link mx-4 text-xl font-bold"
onClick={() => {
scroll (0, 0)
}}>
@@ -205,8 +226,8 @@ const TopNav: FC<Props> = ({ user }) => {
<div ref={navRef} className="relative hidden md:flex h-12 items-center">
<div aria-hidden
className={cn ('absolute inset-y-0 h-12',
'bg-yellow-200 dark:bg-red-950',
'transition-[transform,width] duration-200 ease-out')}
'top-nav-highlight',
highlightTransitionClass)}
style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/>
@@ -214,10 +235,12 @@ const TopNav: FC<Props> = ({ user }) => {
{visibleMenu.map ((item, i) => (
<motion.div
key={item.to}
layoutId={`menu-${ item.name }`}
{...(animationsOff
? { }
: { layoutId: `menu-${ item.name }` })}
animate={{ opacity: moreVsbl ? 0 : 1 }}
transition={{ opacity: { duration: .12 },
layout: { duration: .2, ease: 'easeOut' } }}
transition={{ opacity: opacityTransition,
layout: topNavTransition }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
onMouseEnter={() => setMoreVsbl (false)}>
<PrefetchLink
@@ -225,7 +248,7 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el
}}
className={cn ('relative z-10 flex h-full items-center px-5',
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}>
{item.name}
</PrefetchLink>
@@ -240,7 +263,7 @@ const TopNav: FC<Props> = ({ user }) => {
setMoreVsbl (true)
measure (-1)
}}
className={cn ('relative z-10 flex h-full items-center px-5',
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
&raquo;
</PrefetchLink>
@@ -249,30 +272,28 @@ const TopNav: FC<Props> = ({ user }) => {
<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 ()
<button
type="button"
className="top-nav-mobile-toggle md:hidden ml-auto border-0 bg-transparent pr-4"
onClick={() => {
setMenuOpen (!(menuOpen))
}}>
{menuOpen ? '×' : 'Menu'}
</a>
</button>
</nav>
<AnimatePresence initial={false}>
<motion.div
key="submenu-shell"
layout
className="relative z-20 hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950"
{...(animationsOff ? { }
: { layout: true })}
className="relative z-20 hidden md:block overflow-hidden top-nav-submenu"
animate={{ height: submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
}}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
transition={{ layout: topNavTransition }}
onAnimationComplete={() => {
measure (moreVsbl ? -1 : activeIdx)
}}>
@@ -282,12 +303,14 @@ const TopNav: FC<Props> = ({ user }) => {
<div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3">
<motion.div
transition={{ duration: .2, ease: 'easeOut' }}
{...((item.visible ?? true)
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 } })}
exit: { x: 40, y: -40, opacity: 0 } }))}
className="z-10 h-full flex items-center px-3 font-bold w-28">
<h2>{item.name}</h2>
</motion.div>
@@ -298,30 +321,34 @@ const TopNav: FC<Props> = ({ user }) => {
? (
<motion.div
key={`c-${ i }-${ j }`}
transition={{ duration: .2, ease: 'easeOut' }}
{...((visibleMenu[activeIdx]?.name
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 } })}>
exit: { y: -40, opacity: 0 } }))}>
{subItem.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }-${ j }`}
transition={{ duration: .2, ease: 'easeOut' }}
{...((visibleMenu[activeIdx]?.name
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 } })}>
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
onClick={() => setMoreVsbl (false)}
className="h-full flex items-center px-3">
className="top-nav-menu-link h-full flex items-center px-3">
{subItem.name}
</PrefetchLink>
</motion.div>)))}
@@ -330,129 +357,215 @@ const TopNav: FC<Props> = ({ user }) => {
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
&& (
<div className="relative h-[40px]">
<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' }}>
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<motion.div
key={`c-${ i }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
{item.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
className="h-full flex items-center px-3">
{item.name}
</PrefetchLink>
</motion.div>)))}
</motion.div>
</AnimatePresence>
{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="top-nav-menu-link h-full flex items-center px-3">
{item.name}
</PrefetchLink>
</div>)))}
</div>)
: (
<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={topNavTransition}>
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<motion.div
key={`c-${ i }`}
transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
{item.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }`}
transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ 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}
</PrefetchLink>
</motion.div>)))}
</motion.div>
</AnimatePresence>)}
</div>))}
</motion.div>
</AnimatePresence>
<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/>
{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>
{animationsOff
? (
menuOpen && (
<div
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'items-start')}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'top-nav-mobile-active font-bold'))}
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}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</PrefetchLink>)))}
</motion.div>)}
</AnimatePresence>
</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'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
</AnimatePresence>
{i === openItemIdx && (
<div className="top-nav-submenu w-full">
{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="top-nav-mobile-row 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 ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</div>))
: (
<AnimatePresence initial={false}>
{menuOpen && (
<motion.div
key="spmenu"
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'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={topNavTransition}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'top-nav-mobile-active font-bold'))}
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="top-nav-submenu w-full"
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={topNavTransition}>
{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="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</PrefetchLink>)))}
</motion.div>)}
</AnimatePresence>
</Fragment>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
</AnimatePresence>)}
</>)
}
+12
ファイルの表示
@@ -20,4 +20,16 @@ describe ('TabGroup', () => {
expect (screen.getByText ('Alpha')).toBeInTheDocument ()
expect (screen.queryByText ('Beta')).not.toBeInTheDocument ()
})
it ('renders tabs as buttons and shows the right addon', () => {
render (
<TabGroup rightAddon={<span> 20</span>}>
<Tab name="広場">Plaza</Tab>
</TabGroup>,
)
expect (screen.getByRole ('button', { name: '広場' })).toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '広場' })).not.toBeInTheDocument ()
expect (screen.getByText ('件数 20')).toBeInTheDocument ()
})
})
+26 -13
ファイルの表示
@@ -7,12 +7,15 @@ type TabProps = { name: string
init?: boolean
children: React.ReactNode }
type Props = { children: React.ReactNode }
type Props = {
children: React.ReactNode
rightAddon?: React.ReactNode
}
export const Tab = ({ children }: TabProps) => <>{children}</>
const TabGroup: FC<Props> = ({ children }) => {
const TabGroup: FC<Props> = ({ children, rightAddon }) => {
const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[]
const [current, setCurrent] = useState<number> (() => {
@@ -22,17 +25,27 @@ const TabGroup: FC<Props> = ({ children }) => {
return (
<div className="mt-4">
<div className="flex gap-4">
{tabs.map ((tab, i) => (
<a key={i}
href="#"
className={cn (i === current && 'font-bold')}
onClick={ev => {
ev.preventDefault ()
setCurrent (i)
}}>
{tab.props.name}
</a>))}
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 gap-4">
{tabs.map ((tab, i) => (
<button
key={i}
type="button"
className={cn (
'bg-transparent p-0 text-left text-[hsl(var(--theme-link))] hover:opacity-85',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
i === current && 'font-bold',
)}
onClick={() => {
setCurrent (i)
}}>
{tab.props.name}
</button>))}
</div>
{rightAddon && (
<div className="shrink-0">
{rightAddon}
</div>)}
</div>
<div className="mt-2">
{tabs[current]}
+17 -1
ファイルの表示
@@ -26,7 +26,7 @@ describe ('TagInput', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏' } },
{ params: { q: '虹夏', nico: '0' } },
)
})
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
@@ -41,4 +41,20 @@ describe ('TagInput', () => {
expect (api.apiGet).not.toHaveBeenCalled ()
expect (setValue).toHaveBeenCalledWith (' ')
})
it ('sends nico=1 only when includeNico is true', async () => {
const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
render (<TagInput value="" setValue={setValue} includeNico={true}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏', nico: '1' } },
)
})
})
})
+14 -5
ファイルの表示
@@ -10,13 +10,20 @@ import type { Tag } from '@/types'
type Props = {
includeNico?: boolean
describedBy?: string
invalid?: boolean
value: string
setValue: (value: string) => void }
const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
const TagInput: FC<Props> = ({
includeNico = false,
describedBy,
invalid,
value,
setValue,
}) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
@@ -32,10 +39,12 @@ const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q,
nico: includeNico ? '1' : '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
+19 -8
ファイルの表示
@@ -1,5 +1,7 @@
import { motion } from 'framer-motion'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
@@ -9,12 +11,21 @@ type Props = {
className?: string }
const MainArea: FC<Props> = ({ children, className }) => (
<motion.main
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout="position">
{children}
</motion.main>)
const MainArea: FC<Props> = ({ children, className }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
export default MainArea
return (
<motion.main
transition={{ layout: layoutTransition }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout={animationMode === 'off' ? false : 'position'}>
{children}
</motion.main>)
}
export default MainArea
+10 -2
ファイルの表示
@@ -1,6 +1,8 @@
import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
@@ -68,6 +70,12 @@ const SidebarComponent: FC<Props> = ({
sidebarKey,
side = 'left',
}) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth)
const getContainerWidth = useCallback (() => {
@@ -305,8 +313,8 @@ const SidebarComponent: FC<Props> = ({
return (
<motion.div
ref={rootRef}
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: layoutTransition }}
style={style}
className={cn (
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
+198
ファイルの表示
@@ -0,0 +1,198 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
import type { ClientAnimationMode,
ClientBehaviorSettings,
ClientEmbedAutoLoadMode,
ClientLinkPreloadMode,
ClientTagRelationDisplayMode } from '@/lib/settings'
type Props = {
sectionClassName: string
onDirtyStateChange?: (
dirty: boolean,
discard: () => void,
) => void }
type SegmentedOption<T extends string> = {
value: T
label: string }
type SegmentedControlProps<T extends string> = {
value: T
options: SegmentedOption<T>[]
onChange: (value: T) => void }
type SettingBlockProps = {
title: string
description?: string
children: ReactNode }
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
{ value: 'off', label: 'なし' },
{ value: 'reduced', label: '控えめ' },
{ value: 'normal', label: '通常' }]
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
{ value: 'off', label: '読み込まない' },
{ value: 'manual', label: 'クリック時' },
{ value: 'auto', label: '自動' }]
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [
{ value: 'off', label: 'しない' },
{ value: 'intent', label: 'する' }]
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
{ value: 'flat', label: 'まとめない' },
{ value: 'grouped', label: 'まとめる' }]
const SegmentedControl = <T extends string,> (
{ value, options, onChange }: SegmentedControlProps<T>,
) => {
return (
<div
role="radiogroup"
className="flex flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
{options.map (option => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
className={cn (
'min-w-0 flex-1 rounded-lg px-3 py-2 text-sm font-medium',
'transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2',
value === option.value
? 'bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/40'
: 'bg-transparent text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => onChange (option.value)}>
{option.label}
</button>))}
</div>)
}
const SettingBlock: FC<SettingBlockProps> = (
{ title, description, children },
) => (
<div className="space-y-3 rounded-xl px-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</div>)
const BehaviourSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
const [draftSettings, setDraftSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useEffect (() => {
const current = getClientBehaviorSettings ()
setSavedSettings (current)
setDraftSettings (current)
}, [])
const hasUnsavedChanges = useMemo (
() => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings),
[draftSettings, savedSettings])
const updateDraft = <Key extends keyof ClientBehaviorSettings,> (
key: Key,
value: ClientBehaviorSettings[Key],
) => {
setDraftSettings (current => ({ ...current, [key]: value }))
}
const handleSave = () => {
const next = setClientBehaviorSettings (draftSettings)
setSavedSettings (next)
setDraftSettings (next)
toast ({ title: '動作設定を保存しました' })
}
const handleDiscard = () => {
setDraftSettings (savedSettings)
}
useEffect (() => {
onDirtyStateChange?.(hasUnsavedChanges, handleDiscard)
return () => {
onDirtyStateChange?.(false, () => { })
}
}, [handleDiscard, hasUnsavedChanges, onDirtyStateChange])
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="destructive"
disabled={hasUnsavedChanges === false}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={hasUnsavedChanges === false}
onClick={handleSave}>
</Button>
</div>
</div>
<div className="space-y-4">
<SettingBlock title="アニメーション">
<SegmentedControl
value={draftSettings.animation ?? 'normal'}
options={animationOptions}
onChange={value => updateDraft ('animation', value)}/>
</SettingBlock>
<SettingBlock
title="リンク先の先読み"
description="マウスを置いた時点でリンク先のデータを取得します。">
<SegmentedControl
value={draftSettings.linkPreload ?? 'intent'}
options={linkPreloadOptions}
onChange={value => updateDraft ('linkPreload', value)}/>
</SettingBlock>
<SettingBlock title="タグの親子関係表示">
<SegmentedControl
value={draftSettings.tagRelationDisplay ?? 'grouped'}
options={tagRelationDisplayOptions}
onChange={value => updateDraft ('tagRelationDisplay', value)}/>
</SettingBlock>
<SettingBlock title="埋め込み自動読込">
<SegmentedControl
value={draftSettings.embedAutoLoad ?? 'auto'}
options={embedAutoLoadOptions}
onChange={value => updateDraft ('embedAutoLoad', value)}/>
</SettingBlock>
</div>
</section>)
}
export default BehaviourSettingsSection
+262
ファイルの表示
@@ -0,0 +1,262 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import {
getConflictingShortcutActionIds,
formatKeyBinding,
isSpaceBinding,
keyBindingFromKeyboardEvent,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import { getEffectiveKeyBindings } from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings'
import type { FC } from 'react'
type Props = {
sectionClassName: string
onDirtyStateChange?: (
dirty: boolean,
discard: () => void,
) => void
}
const KeyboardSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
const {
keyboardSettings,
saveKeyboardSettings,
} = useKeyboardShortcutSettings ()
const [capturingActionId, setCapturingActionId] =
useState<ShortcutActionId | null> (null)
const [draftKeyboardSettings, setDraftKeyboardSettings] =
useState<ClientKeyboardSettings> (keyboardSettings)
useEffect (() => {
setDraftKeyboardSettings (keyboardSettings)
}, [keyboardSettings])
const draftEffectiveBindings = useMemo (
() => getEffectiveKeyBindings (draftKeyboardSettings.bindings),
[draftKeyboardSettings.bindings],
)
const draftConflictActionIds = useMemo (
() => getConflictingShortcutActionIds (draftEffectiveBindings),
[draftEffectiveBindings],
)
const hasUnsavedChanges = useMemo (
() => JSON.stringify (draftKeyboardSettings) !== JSON.stringify (keyboardSettings),
[draftKeyboardSettings, keyboardSettings],
)
useEffect (() => {
if (capturingActionId == null)
return
const onKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || event.key === 'Process')
return
event.preventDefault ()
event.stopPropagation ()
if (event.key === 'Escape')
{
setCapturingActionId (null)
return
}
if (event.key === 'Backspace' || event.key === 'Delete')
{
setDraftKeyboardSettings (current => ({
...current,
bindings: {
...(current.bindings ?? { }),
[capturingActionId]: null,
},
}))
setCapturingActionId (null)
return
}
const binding = keyBindingFromKeyboardEvent (event)
if (binding == null)
return
if (isSpaceBinding (binding))
{
toast ({ title: 'Space は今は割り当てられません.' })
return
}
setDraftKeyboardSettings (current => ({
...current,
bindings: {
...(current.bindings ?? { }),
[capturingActionId]: binding,
},
}))
setCapturingActionId (null)
}
document.addEventListener ('keydown', onKeyDown, true)
return () => document.removeEventListener ('keydown', onKeyDown, true)
}, [capturingActionId])
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
...definition,
currentBinding: draftEffectiveBindings[definition.id],
hasConflict: draftConflictActionIds.has (definition.id),
hasCustomBinding: Object.prototype.hasOwnProperty.call (
draftKeyboardSettings.bindings ?? { },
definition.id,
),
})), [
draftConflictActionIds,
draftEffectiveBindings,
draftKeyboardSettings.bindings,
])
const handleSave = () => {
saveKeyboardSettings (draftKeyboardSettings)
toast ({ title: 'キーボード設定を保存しました' })
}
const handleDiscard = () => {
setDraftKeyboardSettings (keyboardSettings)
setCapturingActionId (null)
}
useEffect (() => {
onDirtyStateChange?.(hasUnsavedChanges, handleDiscard)
return () => {
onDirtyStateChange?.(false, () => { })
}
}, [handleDiscard, hasUnsavedChanges, onDirtyStateChange])
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setDraftKeyboardSettings (current => ({
...current,
bindings: { },
}))
}}>
</Button>
<Button
type="button"
variant="destructive"
disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
onClick={handleSave}>
</Button>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={draftKeyboardSettings.enabled !== false}
onChange={event => {
setDraftKeyboardSettings (current => ({
...current,
enabled: event.target.checked,
}))
}}/>
<span></span>
</label>
{capturingActionId != null && (
<p className="text-sm text-muted-foreground">
Escape Backspace / Delete
</p>)}
<div className="overflow-x-auto">
<table className="w-full min-w-[52rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
<td className="p-2 font-mono">
{formatKeyBinding (row.currentBinding)}
</td>
<td className="p-2">
<Button
type="button"
variant={capturingActionId === row.id ? 'default' : 'outline'}
className="h-8"
onClick={() => {
setCapturingActionId (
capturingActionId === row.id ? null : row.id,
)
}}>
{capturingActionId === row.id ? '入力待ち…' : '変更'}
</Button>
</td>
<td className="p-2">
<Button
type="button"
variant="ghost"
className="h-8"
disabled={!(row.hasCustomBinding)}
onClick={() => {
setDraftKeyboardSettings (current => {
const nextBindings = { ...(current.bindings ?? { }) }
delete nextBindings[row.id]
return {
...current,
bindings: nextBindings,
}
})
}}>
</Button>
</td>
<td className="p-2">
{row.hasConflict
? <span className="text-red-600 dark:text-red-400"></span>
: 'なし'}
</td>
</tr>))}
</tbody>
</table>
</div>
</section>)
}
export default KeyboardSettingsSection
+87
ファイルの表示
@@ -0,0 +1,87 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
formatKeyBinding,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import type {
KeyBinding,
ShortcutActionId,
ShortcutScope,
} from '@/lib/keyboardShortcuts'
import type { FC } from 'react'
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
activeScope: ShortcutScope
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
conflictActionIds: Set<ShortcutActionId>
availableActionIds: Set<ShortcutActionId>
}
const ShortcutHelpDialog: FC<Props> = (
{
open,
onOpenChange,
activeScope,
effectiveBindings,
conflictActionIds,
availableActionIds,
},
) => {
const rows = SHORTCUT_DEFINITIONS
.filter (definition =>
definition.scope === 'global' || definition.scope === activeScope)
.filter (definition => availableActionIds.has (definition.id))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
<td className="p-2 font-mono">
{formatKeyBinding (effectiveBindings[row.id])}
</td>
<td className="p-2">
{conflictActionIds.has (row.id)
? <span className="text-red-600 dark:text-red-400"></span>
: 'なし'}
</td>
</tr>))}
</tbody>
</table>
</div>
</DialogContent>
</Dialog>)
}
export default ShortcutHelpDialog
+133 -22
ファイルの表示
@@ -10,6 +10,10 @@
{
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
@@ -29,12 +33,40 @@
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--theme-link: 221.2 83.2% 53.3%;
--top-nav-root-bg-mobile: #fef08a;
--top-nav-root-bg-desktop: #fefce8;
--top-nav-active-bg: #fef08a;
--top-nav-submenu-bg: #fef08a;
--top-nav-mobile-menu-bg: #fef08a;
--top-nav-mobile-active-bg: #fefce8;
--top-nav-brand-link: #db2777;
--top-nav-menu-link: #1d4ed8;
--tag-colour-deerjikist: #9f1239;
--tag-colour-deerjikist-hover: #b62a51;
--tag-colour-meme: #6b21a8;
--tag-colour-meme-hover: #8238bf;
--tag-colour-character: #4d7c0f;
--tag-colour-character-hover: #649326;
--tag-colour-general: #155e75;
--tag-colour-general-hover: #2c758c;
--tag-colour-material: #c2410c;
--tag-colour-material-hover: #d95823;
--tag-colour-meta: #a16207;
--tag-colour-meta-hover: #b8791e;
--tag-colour-nico: #4b5563;
--tag-colour-nico-hover: #626c7a;
}
.dark
{
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
@@ -54,20 +86,28 @@
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--top-nav-root-bg-mobile: #230505;
--top-nav-root-bg-desktop: #230505;
--top-nav-active-bg: #450a0a;
--top-nav-submenu-bg: #450a0a;
--top-nav-mobile-menu-bg: #230505;
--top-nav-mobile-active-bg: #450a0a;
--top-nav-brand-link: #f9a8d4;
--top-nav-menu-link: #93c5fd;
}
body
{
@apply overflow-x-clip;
@apply overflow-x-clip bg-background text-foreground;
}
a
{
@apply text-blue-700 dark:text-blue-300;
color: hsl(var(--theme-link));
}
a:hover
{
@apply text-blue-500 dark:text-blue-100;
color: color-mix(in oklab, hsl(var(--theme-link)), white 18%);
}
}
@@ -78,8 +118,8 @@
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color: hsl(var(--foreground));
background-color: hsl(var(--background));
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -87,6 +127,23 @@
-moz-osx-font-smoothing: grayscale;
}
:root[data-animation='reduced'] *,
:root[data-animation='off'] *
{
scroll-behavior: auto !important;
}
:root[data-animation='off'] *,
:root[data-animation='off'] *::before,
:root[data-animation='off'] *::after
{
animation-duration: 0ms !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0ms !important;
transition-delay: 0ms !important;
}
a
{
font-weight: 500;
@@ -97,28 +154,15 @@ body
{
margin: 0;
min-width: 320px;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
#root
{
min-height: 100dvh;
}
@media (prefers-color-scheme: light)
{
:root
{
color: #213547;
background-color: #ffffff;
}
a:hover
{
color: #747bff;
}
button
{
background-color: #f9f9f9;
}
background: hsl(var(--background));
color: hsl(var(--foreground));
}
@keyframes wiki-blink
@@ -138,6 +182,73 @@ body
position: relative;
}
.tag-link-colour
{
color: var(--tag-link-colour);
}
.tag-link-hover-colour:hover
{
color: var(--tag-link-hover-colour) !important;
}
.top-nav-root
{
background: var(--top-nav-root-bg-mobile);
}
@media (min-width: 768px)
{
.top-nav-root
{
background: var(--top-nav-root-bg-desktop);
}
}
.top-nav-brand-link,
.top-nav-mobile-toggle
{
color: var(--top-nav-brand-link);
}
.top-nav-brand-link:hover,
.top-nav-mobile-toggle:hover
{
color: color-mix(in oklab, var(--top-nav-brand-link), white 18%);
}
.top-nav-menu-link,
.top-nav-mobile-row
{
color: var(--top-nav-menu-link);
}
.top-nav-menu-link:hover,
.top-nav-mobile-row:hover
{
color: color-mix(in oklab, var(--top-nav-menu-link), white 18%);
}
.top-nav-highlight
{
background: var(--top-nav-active-bg);
}
.top-nav-submenu
{
background: var(--top-nav-submenu-bg);
}
.top-nav-mobile-menu
{
background: var(--top-nav-mobile-menu-bg);
}
.top-nav-mobile-active
{
background: var(--top-nav-mobile-active-bg);
}
.tag-marquee__animated
{
position: absolute;
+34
ファイルの表示
@@ -0,0 +1,34 @@
import type { Transition } from 'framer-motion'
import type { ClientAnimationMode } from '@/lib/settings'
type ClientAnimationTransitionOptions = {
normal: Transition
reduced?: Transition
off?: Transition }
export const clientAnimationsOff = (
animationMode: ClientAnimationMode,
): boolean =>
animationMode === 'off'
export const clientAnimationTransition = (
animationMode: ClientAnimationMode,
{ normal, reduced, off }: ClientAnimationTransitionOptions,
): Transition => {
if (animationMode === 'off')
return off ?? { duration: 0 }
if (animationMode === 'reduced')
return reduced ?? { duration: .08, ease: 'linear' as const }
return normal
}
export const clientScrollBehaviour = (
animationMode: ClientAnimationMode,
): ScrollBehavior =>
animationMode === 'off' ? 'auto' : 'smooth'
+65
ファイルの表示
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_KEY_BINDINGS,
formatKeyBinding,
getConflictingShortcutActionIds,
isEditableEventTarget,
keyBindingFromKeyboardEvent,
resolveShortcutScope,
SHORTCUT_DEFINITIONS_BY_ID } from '@/lib/keyboardShortcuts'
describe ('keyboardShortcuts', () => {
it ('defines the default key bindings used by the settings screen', () => {
expect (SHORTCUT_DEFINITIONS_BY_ID['global.openShortcutHelp']).toMatchObject ({
label: 'ショートカット一覧を開く',
scope: 'global',
defaultBinding: { key: '?', shift: true } })
expect (DEFAULT_KEY_BINDINGS['global.focusSearch']).toEqual ({ key: '/' })
expect (DEFAULT_KEY_BINDINGS['navigation.posts']).toEqual ({ key: 'p' })
expect (DEFAULT_KEY_BINDINGS['pagination.next']).toEqual ({ key: 'ArrowRight' })
expect (DEFAULT_KEY_BINDINGS['settings.theme']).toEqual ({ key: '3' })
})
it ('formats key bindings for display', () => {
expect (formatKeyBinding ({ key: '?', shift: true })).toBe ('Shift + ?')
expect (formatKeyBinding ({ key: '/' })).toBe ('/')
expect (formatKeyBinding ({ key: 'p' })).toBe ('P')
expect (formatKeyBinding ({ key: 'ArrowRight' })).toBe ('ArrowRight')
expect (formatKeyBinding (null)).toBe ('未割当')
})
it ('normalises keyboard events without assigning space by default', () => {
const event = { key: 'P',
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false } as KeyboardEvent
expect (keyBindingFromKeyboardEvent (event)).toEqual ({ key: 'p' })
expect (DEFAULT_KEY_BINDINGS['pagination.next']).not.toEqual ({ key: ' ' })
})
it ('detects conflicting action bindings', () => {
const conflicts = getConflictingShortcutActionIds ({
...DEFAULT_KEY_BINDINGS,
'navigation.posts': { key: 'p' },
'navigation.tags': { key: 'p' } })
expect (conflicts.has ('navigation.posts')).toBe (true)
expect (conflicts.has ('navigation.tags')).toBe (true)
expect (conflicts.has ('navigation.wiki')).toBe (false)
})
it ('resolves route scopes and editable event targets', () => {
const input = document.createElement ('input')
const div = document.createElement ('div')
div.setAttribute ('role', 'textbox')
expect (resolveShortcutScope ('/posts/search')).toBe ('postSearch')
expect (resolveShortcutScope ('/users/settings')).toBe ('settings')
expect (resolveShortcutScope ('/materials/1')).toBe ('materials')
expect (isEditableEventTarget (input)).toBe (true)
expect (isEditableEventTarget (div)).toBe (true)
expect (isEditableEventTarget (document.createElement ('button'))).toBe (false)
})
})
+328
ファイルの表示
@@ -0,0 +1,328 @@
export type ShortcutScope =
| 'global'
| 'posts'
| 'postSearch'
| 'tags'
| 'settings'
| 'wiki'
| 'materials'
export type ShortcutActionId =
| 'global.openShortcutHelp'
| 'global.focusSearch'
| 'navigation.posts'
| 'navigation.tags'
| 'navigation.materials'
| 'navigation.wiki'
| 'pagination.next'
| 'pagination.previous'
| 'settings.account'
| 'settings.behavior'
| 'settings.theme'
| 'settings.keyboard'
export type KeyBinding = {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}
export type ShortcutDefinition = {
id: ShortcutActionId
label: string
scope: ShortcutScope
defaultBinding: KeyBinding | null
}
export const SHORTCUT_SCOPE_LABELS: Record<ShortcutScope, string> = {
global: '全画面',
posts: '広場',
postSearch: '広場検索',
tags: 'タグ',
settings: '設定',
wiki: 'Wiki',
materials: '素材',
}
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
{
id: 'global.openShortcutHelp',
label: 'ショートカット一覧を開く',
scope: 'global',
defaultBinding: { key: '?', shift: true },
},
{
id: 'global.focusSearch',
label: '検索欄へ移動',
scope: 'global',
defaultBinding: { key: '/' },
},
{
id: 'navigation.posts',
label: '広場へ移動',
scope: 'global',
defaultBinding: { key: 'p' },
},
{
id: 'navigation.tags',
label: 'タグへ移動',
scope: 'global',
defaultBinding: { key: 't' },
},
{
id: 'navigation.materials',
label: '素材へ移動',
scope: 'global',
defaultBinding: { key: 'm' },
},
{
id: 'navigation.wiki',
label: 'Wiki へ移動',
scope: 'global',
defaultBinding: { key: 'w' },
},
{
id: 'pagination.next',
label: '次のページへ移動',
scope: 'global',
defaultBinding: { key: 'ArrowRight' },
},
{
id: 'pagination.previous',
label: '前のページへ移動',
scope: 'global',
defaultBinding: { key: 'ArrowLeft' },
},
{
id: 'settings.account',
label: '設定: アカウント',
scope: 'settings',
defaultBinding: { key: '1' },
},
{
id: 'settings.behavior',
label: '設定: 動作',
scope: 'settings',
defaultBinding: { key: '2' },
},
{
id: 'settings.theme',
label: '設定: テーマ',
scope: 'settings',
defaultBinding: { key: '3' },
},
{
id: 'settings.keyboard',
label: '設定: キーボード',
scope: 'settings',
defaultBinding: { key: '4' },
},
]
export const SHORTCUT_DEFINITIONS_BY_ID =
SHORTCUT_DEFINITIONS.reduce<Record<ShortcutActionId, ShortcutDefinition>> (
(definitions, definition) => {
definitions[definition.id] = definition
return definitions
},
{
'global.openShortcutHelp': SHORTCUT_DEFINITIONS[0],
'global.focusSearch': SHORTCUT_DEFINITIONS[1],
'navigation.posts': SHORTCUT_DEFINITIONS[2],
'navigation.tags': SHORTCUT_DEFINITIONS[3],
'navigation.materials': SHORTCUT_DEFINITIONS[4],
'navigation.wiki': SHORTCUT_DEFINITIONS[5],
'pagination.next': SHORTCUT_DEFINITIONS[6],
'pagination.previous': SHORTCUT_DEFINITIONS[7],
'settings.account': SHORTCUT_DEFINITIONS[8],
'settings.behavior': SHORTCUT_DEFINITIONS[9],
'settings.theme': SHORTCUT_DEFINITIONS[10],
'settings.keyboard': SHORTCUT_DEFINITIONS[11],
},
)
export const DEFAULT_KEY_BINDINGS =
SHORTCUT_DEFINITIONS.reduce<Record<ShortcutActionId, KeyBinding | null>> (
(bindings, definition) => {
bindings[definition.id] = definition.defaultBinding
return bindings
},
{
'global.openShortcutHelp': null,
'global.focusSearch': null,
'navigation.posts': null,
'navigation.tags': null,
'navigation.materials': null,
'navigation.wiki': null,
'pagination.next': null,
'pagination.previous': null,
'settings.account': null,
'settings.behavior': null,
'settings.theme': null,
'settings.keyboard': null,
},
)
const normaliseKey = (key: string): string => {
if (key === 'Spacebar')
return ' '
if (key.length === 1 && key !== ' ')
return key.toLowerCase ()
return key
}
const isModifierKey = (key: string): boolean =>
key === 'Shift'
|| key === 'Control'
|| key === 'Alt'
|| key === 'Meta'
export const normaliseKeyBinding = (value: unknown): KeyBinding | null => {
if (!(value) || typeof value !== 'object')
return null
const binding = value as Partial<KeyBinding>
if (typeof binding.key !== 'string')
return null
const key = normaliseKey (binding.key)
if (key.length === 0 || isModifierKey (key))
return null
return {
key,
...(binding.ctrl ? { ctrl: true } : { }),
...(binding.shift ? { shift: true } : { }),
...(binding.alt ? { alt: true } : { }),
...(binding.meta ? { meta: true } : { }),
}
}
export const serialiseKeyBinding = (binding: KeyBinding | null): string | null => {
if (binding == null)
return null
const parts = [
binding.ctrl ? 'Ctrl' : null,
binding.shift ? 'Shift' : null,
binding.alt ? 'Alt' : null,
binding.meta ? 'Meta' : null,
binding.key,
].filter ((part): part is string => part != null)
return parts.join ('+')
}
const keyLabel = (key: string): string => {
switch (key)
{
case ' ':
return 'Space'
case 'ArrowLeft':
return 'ArrowLeft'
case 'ArrowRight':
return 'ArrowRight'
case 'ArrowUp':
return 'ArrowUp'
case 'ArrowDown':
return 'ArrowDown'
case 'Escape':
return 'Escape'
case 'Backspace':
return 'Backspace'
case 'Delete':
return 'Delete'
default:
return key.length === 1 ? key.toUpperCase () : key
}
}
export const formatKeyBinding = (binding: KeyBinding | null): string => {
if (binding == null)
return '未割当'
return [
binding.ctrl ? 'Ctrl' : null,
binding.shift ? 'Shift' : null,
binding.alt ? 'Alt' : null,
binding.meta ? 'Meta' : null,
keyLabel (binding.key),
].filter ((part): part is string => part != null)
.join (' + ')
}
export const keyBindingFromKeyboardEvent = (
event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>,
): KeyBinding | null => {
const key = normaliseKey (event.key)
if (key.length === 0 || isModifierKey (key))
return null
return {
key,
...(event.ctrlKey ? { ctrl: true } : { }),
...(event.shiftKey ? { shift: true } : { }),
...(event.altKey ? { alt: true } : { }),
...(event.metaKey ? { meta: true } : { }),
}
}
export const isEditableEventTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element))
return false
return target.closest (
'input, textarea, select, [contenteditable="true"], [role="textbox"]',
) != null
}
export const getConflictingShortcutActionIds = (
bindings: Record<ShortcutActionId, KeyBinding | null>,
): Set<ShortcutActionId> => {
const grouped = new Map<string, ShortcutActionId[]> ()
Object.entries (bindings).forEach (([actionId, binding]) => {
const serialised = serialiseKeyBinding (binding)
if (serialised == null)
return
const ids = grouped.get (serialised) ?? []
ids.push (actionId as ShortcutActionId)
grouped.set (serialised, ids)
})
return new Set (
[...grouped.values ()]
.filter (actionIds => actionIds.length > 1)
.flat (),
)
}
export const resolveShortcutScope = (pathname: string): ShortcutScope => {
if (pathname.startsWith ('/posts/search'))
return 'postSearch'
if (pathname.startsWith ('/posts'))
return 'posts'
if (pathname.startsWith ('/tags'))
return 'tags'
if (pathname.startsWith ('/users/settings'))
return 'settings'
if (pathname.startsWith ('/wiki'))
return 'wiki'
if (pathname.startsWith ('/materials'))
return 'materials'
return 'global'
}
export const isSpaceBinding = (binding: KeyBinding | null): boolean =>
binding?.key === ' '
+108
ファイルの表示
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { applyClientAnimationMode,
applyThemeTokens,
buildThemeTokens,
CLIENT_SETTINGS_STORAGE_KEY,
DARK_THEME_TOKENS,
DEFAULT_POST_LIST_ORDER,
getClientListLimit,
getClientListOrder,
getClientThemeMode,
LIGHT_THEME_TOKENS,
loadClientSettings,
setClientListSettings,
setClientThemeAppearance } from '@/lib/settings'
import type { FetchPostsOrder } from '@/types'
const installMatchMedia = (matches: boolean) => {
Object.defineProperty (window, 'matchMedia', {
configurable: true,
value: vi.fn ().mockImplementation (() => ({
matches,
addEventListener: vi.fn (),
removeEventListener: vi.fn () })) })
}
describe ('settings', () => {
beforeEach (() => {
localStorage.clear ()
document.documentElement.removeAttribute ('data-animation')
document.documentElement.removeAttribute ('style')
document.documentElement.className = ''
installMatchMedia (false)
})
it ('stores list settings under the shared client settings key', () => {
setClientListSettings ('postSearch', {
limit: 50,
order: 'title:asc' })
expect (getClientListLimit ('postSearch')).toBe (50)
expect (getClientListOrder<FetchPostsOrder> ('postSearch')).toBe ('title:asc')
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
.toMatchObject ({ lists: { postSearch: { limit: 50, order: 'title:asc' } } })
})
it ('ignores invalid list limits and keeps stored order strings', () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { postList: { limit: 30, order: DEFAULT_POST_LIST_ORDER } } }))
expect (getClientListLimit ('postList')).toBeNull ()
expect (getClientListOrder<FetchPostsOrder> ('postList')).toBe (
DEFAULT_POST_LIST_ORDER)
})
it ('stores theme mode and light/dark slot selections in client settings', () => {
setClientThemeAppearance ({
themeMode: 'dark',
activeLightThemeSlotNo: 2,
activeDarkThemeSlotNo: 3 })
expect (getClientThemeMode ()).toBe ('dark')
expect (loadClientSettings ().appearance).toMatchObject ({
themeMode: 'dark',
activeLightThemeSlotNo: 2,
activeDarkThemeSlotNo: 3 })
})
it ('applies animation mode to the document dataset', () => {
applyClientAnimationMode ('off')
expect (document.documentElement.dataset.animation).toBe ('off')
applyClientAnimationMode ('reduced')
expect (document.documentElement.dataset.animation).toBe ('reduced')
applyClientAnimationMode ('normal')
expect (document.documentElement.dataset.animation).toBeUndefined ()
})
it ('normalises TopNav menu link tokens to complete CSS colours', () => {
const tokens = buildThemeTokens ('light', {
link: '221.2 83.2% 53.3%',
topNavMenuLink: '221.2 83.2% 53.3%' })
expect (tokens.link).toBe ('221.2 83.2% 53.3%')
expect (tokens.topNavMenuLink).toBe ('hsl(221.2 83.2% 53.3%)')
})
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe (
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
DARK_THEME_TOKENS.topNavActiveBackground)
})
it ('sets shadcn tokens and TopNav tokens with their different value formats', () => {
const tokens = buildThemeTokens ('dark', { link: '213.1 93.9% 67.8%' })
applyThemeTokens (tokens)
expect (document.documentElement.style.getPropertyValue ('--theme-link')).toBe (
'213.1 93.9% 67.8%')
expect (document.documentElement.style.getPropertyValue ('--top-nav-menu-link')).toBe (
'hsl(213.1 93.9% 67.8%)')
})
})
ファイル差分が大きすぎるため省略します 差分を読込み
+26
ファイルの表示
@@ -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
}
+322
ファイルの表示
@@ -0,0 +1,322 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
import {
DEFAULT_KEY_BINDINGS,
getConflictingShortcutActionIds,
isEditableEventTarget,
keyBindingFromKeyboardEvent,
resolveShortcutScope,
serialiseKeyBinding,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import {
getClientKeyboardSettings,
getEffectiveKeyBindings,
setClientKeyboardSettings,
} from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type {
KeyBinding,
ShortcutActionId,
ShortcutScope,
} from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings'
import type { PropsWithChildren } from 'react'
type ShortcutHandler = () => void
type ShortcutHandlers = Partial<Record<ShortcutActionId, ShortcutHandler>>
type KeyboardShortcutsContextValue = {
activeScope: ShortcutScope
keyboardSettings: ClientKeyboardSettings
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
conflictActionIds: Set<ShortcutActionId>
saveKeyboardSettings: (settings: ClientKeyboardSettings) => void
registerHandlers: (handlers: ShortcutHandlers) => () => void
}
const KeyboardShortcutsContext =
createContext<KeyboardShortcutsContextValue | null> (null)
const isFocusableShortcutTarget = (element: HTMLElement): boolean => {
if (element.closest ('[hidden], [aria-hidden="true"]') != null)
return false
if (
(element instanceof HTMLInputElement
|| element instanceof HTMLButtonElement
|| element instanceof HTMLSelectElement
|| element instanceof HTMLTextAreaElement)
&& element.disabled
)
return false
const style = window.getComputedStyle (element)
if (
style.display === 'none'
|| style.visibility === 'hidden'
|| style.pointerEvents === 'none'
)
return false
return element.getClientRects ().length > 0
}
const focusSearchTarget = (): void => {
const targets = [
...document.querySelectorAll<HTMLElement> (
'[data-shortcut-focus-search="true"]',
),
]
const target = targets.find (isFocusableShortcutTarget)
if (target == null)
return
target.focus ()
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
target.select ()
}
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation ()
const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
const [registeredHandlers, setRegisteredHandlers] =
useState<Record<number, ShortcutHandlers>> ({ })
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
const registrationIdRef = useRef (0)
const activeScope = useMemo<ShortcutScope> (
() => resolveShortcutScope (location.pathname),
[location.pathname],
)
const effectiveBindings = useMemo (
() => getEffectiveKeyBindings (keyboardSettings.bindings),
[keyboardSettings.bindings],
)
const conflictActionIds = useMemo (
() => getConflictingShortcutActionIds (effectiveBindings),
[effectiveBindings],
)
const mergedRegisteredHandlers = useMemo<ShortcutHandlers> (
() => Object.values (registeredHandlers).reduce<ShortcutHandlers> (
(handlers, currentHandlers) => ({ ...handlers, ...currentHandlers }),
{ },
),
[registeredHandlers],
)
const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => {
const next = setClientKeyboardSettings (settings)
setKeyboardSettingsState (next)
}, [])
const registerHandlers = useCallback ((handlers: ShortcutHandlers) => {
const registrationId = ++registrationIdRef.current
setRegisteredHandlers (current => ({
...current,
[registrationId]: handlers,
}))
return () => {
setRegisteredHandlers (current => {
const next = { ...current }
delete next[registrationId]
return next
})
}
}, [])
const guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => {
if (confirmed)
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({
'global.openShortcutHelp': () => {
setShortcutHelpOpen (true)
},
'global.focusSearch': () => {
focusSearchTarget ()
},
'navigation.posts': () => guardedNavigate ('/posts'),
'navigation.tags': () => guardedNavigate ('/tags'),
'navigation.materials': () => guardedNavigate ('/materials'),
'navigation.wiki': () => guardedNavigate ('/wiki'),
'settings.account': () => guardedNavigate ('/users/settings?tab=account'),
'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'),
'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'),
'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'),
}),
[guardedNavigate],
)
const availableActionIds = useMemo<Set<ShortcutActionId>> (
() => new Set (
SHORTCUT_DEFINITIONS
.filter (definition =>
definition.scope === 'global' || definition.scope === activeScope)
.filter (definition =>
builtinHandlers[definition.id] != null
|| mergedRegisteredHandlers[definition.id] != null)
.map (definition => definition.id),
),
[activeScope, builtinHandlers, mergedRegisteredHandlers],
)
useEffect (() => {
const onKeyDown = (event: KeyboardEvent) => {
if (
keyboardSettings.enabled === false
|| event.defaultPrevented
|| event.isComposing
|| event.key === 'Process'
|| event.repeat
)
return
if (isEditableEventTarget (event.target))
return
if (shortcutHelpOpen)
return
const binding = keyBindingFromKeyboardEvent (event)
if (binding == null)
return
const serialisedBinding = serialiseKeyBinding (binding)
if (serialisedBinding == null)
return
const candidates = SHORTCUT_DEFINITIONS.filter (definition => (
definition.scope === 'global'
|| definition.scope === activeScope
)).filter (definition => {
const currentBinding = effectiveBindings[definition.id]
return (
currentBinding != null
&& serialiseKeyBinding (currentBinding) === serialisedBinding
)
}).filter (definition => !(conflictActionIds.has (definition.id)))
if (candidates.length !== 1)
return
const actionId = candidates[0].id
const handler = mergedRegisteredHandlers[actionId] ?? builtinHandlers[actionId]
if (handler == null)
return
event.preventDefault ()
handler ()
}
document.addEventListener ('keydown', onKeyDown)
return () => document.removeEventListener ('keydown', onKeyDown)
}, [
activeScope,
conflictActionIds,
effectiveBindings,
builtinHandlers,
keyboardSettings.enabled,
mergedRegisteredHandlers,
shortcutHelpOpen,
])
const value = useMemo<KeyboardShortcutsContextValue> (() => ({
activeScope,
keyboardSettings,
effectiveBindings,
conflictActionIds,
saveKeyboardSettings,
registerHandlers,
}), [
activeScope,
conflictActionIds,
effectiveBindings,
keyboardSettings,
registerHandlers,
saveKeyboardSettings,
])
return (
<KeyboardShortcutsContext.Provider value={value}>
{children}
<ShortcutHelpDialog
open={shortcutHelpOpen}
onOpenChange={setShortcutHelpOpen}
activeScope={activeScope}
effectiveBindings={effectiveBindings}
conflictActionIds={conflictActionIds}
availableActionIds={availableActionIds}/>
</KeyboardShortcutsContext.Provider>)
}
export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => {
const context = useContext (KeyboardShortcutsContext)
if (context == null)
throw new Error ('KeyboardShortcutsProvider が必要です.')
return context
}
export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => {
const { registerHandlers } = useKeyboardShortcutSettings ()
const handlersRef = useRef (handlers)
const actionIdsKey = Object.keys (handlers).sort ().join ('|')
useEffect (() => {
handlersRef.current = handlers
}, [handlers])
const stableHandlers = useMemo<ShortcutHandlers> (() => {
return actionIdsKey.split ('|').filter (actionId => actionId !== '')
.reduce<ShortcutHandlers> ((nextHandlers, actionId) => {
const key = actionId as ShortcutActionId
nextHandlers[key] = () => {
handlersRef.current[key]?.()
}
return nextHandlers
}, { })
}, [actionIdsKey])
useEffect (
() => registerHandlers (stableHandlers),
[registerHandlers, stableHandlers],
)
}
export {
DEFAULT_KEY_BINDINGS,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
}
+69
ファイルの表示
@@ -0,0 +1,69 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, PropsWithChildren } from 'react'
type UnsavedChangesSource = {
dirty: boolean
discard: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource | null,
) => void
confirmDiscardNavigation: () => Promise<boolean> }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null,
) => {
setSource (nextSource)
}, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
if (!(source?.dirty))
return true
const confirmed = await dialogue.confirm ({
title: '未保存の変更があります',
description: 'このまま移動すると、保存していない変更は失われます。',
cancelText: 'このページに残る',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
await source.discard ()
return true
}, [dialogue, source])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true,
registerUnsavedChangesSource,
confirmDiscardNavigation,
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
</UnsavedChangesGuardContext.Provider>)
}
export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => {
const context = useContext (UnsavedChangesGuardContext)
if (context == null)
throw new Error ('UnsavedChangesGuardProvider が必要です.')
return context
}
+3
ファイルの表示
@@ -4,6 +4,7 @@ import { HelmetProvider } from 'react-helmet-async'
import '@/index.css'
import App from '@/App'
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
const helmetContext = { }
@@ -13,6 +14,8 @@ const client = new QueryClient ({
gcTime: 30 * 60 * 1000,
retry: 1 } } })
applyClientAnimationMode (getClientAnimationMode ())
createRoot (document.getElementById ('root')!).render (
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}>
+14 -10
ファイルの表示
@@ -24,6 +24,11 @@ import { recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery'
import { isQuestionHardFilteredAfterAnswers,
monthForCondition } from '@/lib/gekanatorQuestionFilters'
import { gekanatorKeys } from '@/lib/queryKeys'
import {
getClientGekanatorBackgroundMotion,
setClientGekanatorBackgroundMotion,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
@@ -185,7 +190,6 @@ const softenedAnswerWeight = .35
const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1'
const recentGamesStorageKey = 'gekanator:recent-games:v1'
const backgroundMotionStorageKey = 'gekanator:background-motion:v1'
const maxStoredRecentGames = 12
const specialOriginalMonthDayLabels: Record<string, string> = {
'1-1': '元日',
@@ -429,18 +433,13 @@ const storeRecentGameSummary = (
const loadBackgroundMotionMode = (): BackgroundMotionMode => {
const fallbackMode = 'on'
try
{
const raw = localStorage.getItem (backgroundMotionStorageKey)
if (raw === 'off' || raw === 'calm' || raw === 'on')
return raw
return fallbackMode
return getClientGekanatorBackgroundMotion ()
}
catch
{
return fallbackMode
return 'on'
}
}
@@ -3679,6 +3678,8 @@ const expectedAnswerFor = (
const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const storedGame = useMemo (loadStoredGame, [])
const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase)
const queryClient = useQueryClient ()
@@ -3916,7 +3917,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
useEffect (() => {
try
{
localStorage.setItem (backgroundMotionStorageKey, backgroundMotionMode)
setClientGekanatorBackgroundMotion (backgroundMotionMode)
}
catch
{
@@ -4071,9 +4072,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
return reviewGuessedPostId === reviewCorrectPostId
}) ()
const effectiveBackgroundMotionMode = (() => {
if (animationMode === 'off')
return 'off'
if (backgroundMotionMode === 'off')
return 'off'
if (prefersReducedMotion)
if (animationMode === 'reduced' || prefersReducedMotion)
return 'calm'
return backgroundMotionMode
+7 -2
ファイルの表示
@@ -9,14 +9,18 @@ import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchMaterialChanges } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
const MaterialHistoryPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation ()
const navigate = useNavigate ()
const query = new URLSearchParams (location.search)
@@ -55,8 +59,9 @@ const MaterialHistoryPage: FC = () => {
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search])
const handleSearch = (event: FormEvent) => {
event.preventDefault ()
+1 -1
ファイルの表示
@@ -79,7 +79,7 @@ const MaterialNewPage: FC = () => {
<FieldError messages={baseErrors}/>
{/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}>
<FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
+16 -3
ファイルの表示
@@ -15,6 +15,7 @@ import { SITE_TITLE } from '@/config'
import { isApiError } from '@/lib/api'
import { fetchPost, toggleViewedFlg } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound'
@@ -29,7 +30,17 @@ type Props = { user: User | null }
const PostDetailPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const behaviourSettings = useClientBehaviourSettings ()
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
const animationMode = behaviourSettings.animation ?? 'normal'
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 postKey = postsKeys.show (postId)
@@ -134,17 +145,19 @@ const PostDetailPage: FC<Props> = ({ user }) => {
</div>)
})}
{(post.thumbnail || post.thumbnailBase) && (
{thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && (
<motion.div
layoutId={`page-${ id }`}
layoutId={heroLayoutId}
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px]
overflow-hidden rounded-xl pointer-events-none z-50"
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: .2, ease: 'easeOut' }}>
transition={heroTransition}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={thumbnailMode === 'light' ? 'lazy' : 'eager'}
decoding="async"
className="object-cover w-full h-full"/>
</motion.div>)}
+14 -4
ファイルの表示
@@ -12,9 +12,12 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPostChanges, updatePost } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, MouseEvent } from 'react'
@@ -37,6 +40,12 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
const PostHistoryPage: FC = () => {
const dialogue = useDialogue ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const location = useLocation ()
const query = new URLSearchParams (location.search)
@@ -113,8 +122,9 @@ const PostHistoryPage: FC = () => {
}
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search])
const layoutIds: string[] = []
@@ -198,8 +208,8 @@ const PostHistoryPage: FC = () => {
rowSpan={rowsCnt}>
<PrefetchLink to={`/posts/${ change.postId }`}>
<motion.div
layoutId={layoutId}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
layoutId={animationMode === 'off' ? undefined : layoutId}
transition={{ layout: layoutTransition }}>
<img src={change.thumbnail.current
|| change.thumbnailBase.current
|| undefined}
+40 -1
ファイルの表示
@@ -1,9 +1,10 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostListPage from '@/pages/posts/PostListPage'
import { buildPost, buildTag, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (),
@@ -18,6 +19,11 @@ vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/wiki', () => wikiApi)
describe ('PostListPage', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('loads posts from the current query and renders the plaza tab', async () => {
const tag = buildTag ({ name: '虹夏' })
postsApi.fetchPosts.mockResolvedValueOnce ({
@@ -36,6 +42,7 @@ describe ('PostListPage', () => {
match: 'all',
page: 2,
limit: 20,
order: 'original_created_at:desc',
}),
)
})
@@ -71,4 +78,36 @@ describe ('PostListPage', () => {
'/wiki/%E8%99%B9%E5%A4%8F',
)
})
it ('uses stored list limit but keeps the fixed plaza order', async () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { postList: { limit: 50, order: 'title:asc' } } }))
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
renderWithProviders (<PostListPage/>, { route: '/posts' })
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({
limit: 50,
order: 'original_created_at:desc',
}),
)
})
})
it ('updates the limit from the tab right addon and resets to page 1', async () => {
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
renderWithProviders (<PostListPage/>, { route: '/posts?page=3' })
fireEvent.change (await screen.findByLabelText ('件数'), { target: { value: '100' } })
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
)
})
})
})
+74 -5
ファイルの表示
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import PostList from '@/components/PostList'
import PrefetchLink from '@/components/PrefetchLink'
@@ -13,12 +13,24 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
getClientListLimit,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchWikiPageByTitle } from '@/lib/wiki'
import type { FC } from 'react'
import type { WikiPage } from '@/types'
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const n = Number (value)
return n === 20 || n === 50 || n === 100 ? n : null
}
const PostListPage: FC = () => {
const containerRef = useRef<HTMLDivElement | null> (null)
@@ -26,6 +38,7 @@ const PostListPage: FC = () => {
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
const location = useLocation ()
const navigate = useNavigate ()
const query = new URLSearchParams (location.search)
const tagsQuery = query.get ('tags') ?? ''
const anyFlg = query.get ('match') === 'any'
@@ -33,13 +46,18 @@ const PostListPage: FC = () => {
const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery])
const tagsKey = tags.join (' ')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('postList')
?? DEFAULT_POST_LIST_LIMIT
)
const order = 'original_created_at:desc' as const
const keys = {
tags: tagsKey, match, page, limit,
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const
order } as const
const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
@@ -47,6 +65,40 @@ const PostListPage: FC = () => {
const cursor = ''
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('postList', { limit })
}, [limit])
const updatePage = (nextPage: number) => {
const params = new URLSearchParams (location.search)
params.set ('page', String (Math.max (1, nextPage)))
navigate (`${ location.pathname }?${ params.toString () }`)
}
const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => {
const params = new URLSearchParams (location.search)
params.set ('limit', String (next.limit ?? limit))
params.set ('page', '1')
navigate (`${ location.pathname }?${ params.toString () }`)
}
useKeyboardShortcuts (useMemo (() => ({
...(page > 1
? {
'pagination.previous': () => {
updatePage (page - 1)
},
}
: { }),
...(totalPages > 0 && page < totalPages
? {
'pagination.next': () => {
updatePage (Math.min (page + 1, totalPages))
},
}
: { }),
}), [page, totalPages, location.search]))
useLayoutEffect (() => {
scroll (0, 0)
@@ -90,7 +142,24 @@ const PostListPage: FC = () => {
}}/>
<MainArea>
<TabGroup>
<TabGroup
rightAddon={
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground"
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}>
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>
</label>
}>
<Tab name="広場">
{posts.length > 0
? (
+1 -9
ファイルの表示
@@ -30,16 +30,12 @@ describe ('PostNewPage', () => {
expect (screen.getByText ('403')).toBeInTheDocument ()
})
it ('submits a new post with manual title and thumbnail settings', async () => {
it ('submits a new post with manual title and thumbnail fetch UI', async () => {
api.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([])
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
fireEvent.click (checkboxes[0])
fireEvent.click (checkboxes[1])
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
@@ -97,10 +93,6 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
fireEvent.click (checkboxes[0])
fireEvent.click (checkboxes[1])
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
+70 -80
ファイルの表示
@@ -41,16 +41,13 @@ const PostNewPage: FC<Props> = ({ user }) => {
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('')
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false)
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
const [title, setTitle] = useState ('')
const [titleAutoFlg, setTitleAutoFlg] = useState (true)
const [titleLoading, setTitleLoading] = useState (false)
const [url, setURL] = useState ('')
const previousURLRef = useRef ('')
const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
@@ -86,23 +83,17 @@ const PostNewPage: FC<Props> = ({ user }) => {
}
}
const handleURLBlur = () => {
if (!(url) || url === previousURLRef.current)
return
if (titleAutoFlg)
fetchTitle ()
if (thumbnailAutoFlg)
fetchThumbnail ()
previousURLRef.current = url
}
const fetchTitle = useCallback (async () => {
setTitle ('')
setTitleLoading (true)
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
setTitleLoading (false)
try
{
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
}
finally
{
setTitleLoading (false)
}
}, [url])
const fetchThumbnail = useCallback (async () => {
@@ -111,30 +102,26 @@ const PostNewPage: FC<Props> = ({ user }) => {
setThumbnailLoading (true)
if (thumbnailPreviewRef.current)
URL.revokeObjectURL (thumbnailPreviewRef.current)
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url }, responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
'thumbnail.png',
{ type: data.type || 'image/png' }))
setThumbnailLoading (false)
try
{
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url }, responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
'thumbnail.png',
{ type: data.type || 'image/png' }))
}
finally
{
setThumbnailLoading (false)
}
}, [url])
useEffect (() => {
thumbnailPreviewRef.current = thumbnailPreview
}, [thumbnailPreview])
useEffect (() => {
if (titleAutoFlg && url)
fetchTitle ()
}, [fetchTitle, titleAutoFlg, url])
useEffect (() => {
if (thumbnailAutoFlg && url)
fetchThumbnail ()
}, [fetchThumbnail, thumbnailAutoFlg, url])
if (!(editable))
return <Forbidden/>
@@ -156,59 +143,62 @@ const PostNewPage: FC<Props> = ({ user }) => {
onChange={e => setURL (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
onBlur={handleURLBlur}/>)}
className={inputClass (invalid)}/>)}
</FormField>
{/* タイトル */}
<FormField
checkBox={{
label: '自動',
checked: titleAutoFlg,
onChange: ev => setTitleAutoFlg (ev.target.checked)}}
label="タイトル"
messages={fieldErrors.title}>
<FormField label="タイトル" messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleAutoFlg}/>)}
<div className="space-y-2">
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleLoading}/>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>
</div>
</div>)}
</FormField>
{/* サムネール */}
<FormField
checkBox={{
label: '自動',
checked: thumbnailAutoFlg,
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}
label="サムネール"
messages={fieldErrors.thumbnail}>
<FormField label="サムネール" messages={fieldErrors.thumbnail}>
{({ describedBy, invalid }) => (
<>
{thumbnailAutoFlg
? (thumbnailLoading
? <p className="text-gray-500 text-sm">Loading...</p>
: !(thumbnailPreview) && (
<p className="text-gray-500 text-sm">
URL
</p>))
: (
<input type="file"
accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>)}
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>
</div>
{thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)}
<input type="file"
accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
{thumbnailPreview && (
<img src={thumbnailPreview}
alt="preview"
+40
ファイルの表示
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostSearchPage from '@/pages/posts/PostSearchPage'
import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (),
@@ -18,6 +19,7 @@ vi.mock ('@/lib/api', () => api)
describe ('PostSearchPage', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
api.apiGet.mockResolvedValue ([])
})
@@ -40,6 +42,7 @@ describe ('PostSearchPage', () => {
tags: 'x',
match: 'any',
page: 2,
limit: 20,
}),
)
})
@@ -81,4 +84,41 @@ describe ('PostSearchPage', () => {
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument ()
})
it ('uses URL limit before localStorage and stores the active list settings', async () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { postSearch: { limit: 100, order: 'title:asc' } } }))
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
renderWithProviders (
<PostSearchPage/>,
{ route: '/posts/search?limit=50&order=created_at%3Adesc' },
)
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({ limit: 50, order: 'created_at:desc' }),
)
})
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
.toMatchObject ({ lists: { postSearch: { limit: 50, order: 'created_at:desc' } } })
})
it ('moves the compact limit control outside the search form and resets page on change', async () => {
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
renderWithProviders (<PostSearchPage/>, { route: '/posts/search?page=4' })
const limitSelect = await screen.findByLabelText ('件数')
expect (limitSelect.closest ('form')).toBeNull ()
fireEvent.change (limitSelect, { target: { value: '100' } })
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
)
})
})
})
+123 -12
ファイルの表示
@@ -14,8 +14,20 @@ import Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_POST_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
@@ -31,8 +43,40 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
qs.set (k, t)
}
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const n = Number (value)
return n === 20 || n === 50 || n === 100 ? n : null
}
const parseOrder = (value: string | null): FetchPostsOrder | null => {
switch (value)
{
case 'title:asc':
case 'title:desc':
case 'url:asc':
case 'url:desc':
case 'original_created_at:asc':
case 'original_created_at:desc':
case 'created_at:asc':
case 'created_at:desc':
case 'updated_at:asc':
case 'updated_at:desc':
return value
default:
return null
}
}
const PostSearchPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const thumbnailTransition = clientAnimationTransition (
animationMode,
{ normal: { type: 'spring', stiffness: 500, damping: 40, mass: .5 },
reduced: { duration: .08, ease: 'linear' as const } },
)
const location = useLocation ()
const navigate = useNavigate ()
@@ -41,7 +85,11 @@ const PostSearchPage: FC = () => {
[location.search])
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('postSearch')
?? DEFAULT_POST_LIST_LIMIT
)
const qURL = query.get ('url') ?? ''
const qTitle = query.get ('title') ?? ''
@@ -53,7 +101,11 @@ const PostSearchPage: FC = () => {
const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') ?? ''
const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
const order = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchPostsOrder> ('postSearch')
?? DEFAULT_POST_LIST_ORDER
)
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
@@ -83,6 +135,10 @@ const PostSearchPage: FC = () => {
const results = data?.posts ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('postSearch', { limit, order })
}, [limit, order])
useEffect (() => {
setURL (qURL ?? '')
setTitle (qTitle ?? '')
@@ -95,9 +151,11 @@ const PostSearchPage: FC = () => {
setUpdatedFrom (qUpdatedFrom)
setUpdatedTo (qUpdatedTo)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedFrom,
qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, qUpdatedTo, qURL])
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search, qCreatedFrom, qCreatedTo, qMatch,
qOriginalCreatedFrom, qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom,
qUpdatedTo, qURL])
const search = async () => {
const qs = new URLSearchParams ()
@@ -113,6 +171,22 @@ const PostSearchPage: FC = () => {
qs.set ('match', matchType)
qs.set ('page', '1')
qs.set ('order', order)
qs.set ('limit', String (limit))
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const updateListQuery = (next: { limit?: 20 | 50 | 100
order?: FetchPostsOrder }) => {
const qs = new URLSearchParams (location.search)
qs.set ('limit', String (next.limit ?? limit))
qs.set ('order', next.order ?? order)
qs.set ('page', '1')
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const updatePage = (nextPage: number) => {
const qs = new URLSearchParams (location.search)
qs.set ('page', String (Math.max (1, nextPage)))
navigate (`${ location.pathname }?${ qs.toString () }`)
}
@@ -121,6 +195,23 @@ const PostSearchPage: FC = () => {
search ()
}
useKeyboardShortcuts (useMemo (() => ({
...(page > 1
? {
'pagination.previous': () => {
updatePage (page - 1)
},
}
: { }),
...(totalPages > 0 && page < totalPages
? {
'pagination.next': () => {
updatePage (Math.min (page + 1, totalPages))
},
}
: { }),
}), [page, totalPages, location.search]))
const defaultDirection = { title: 'asc',
url: 'asc',
original_created_at: 'desc',
@@ -134,7 +225,24 @@ const PostSearchPage: FC = () => {
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground"
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}>
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>
</label>
</div>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
@@ -142,6 +250,7 @@ const PostSearchPage: FC = () => {
{({ invalid }) => (
<input
type="text"
data-shortcut-focus-search="true"
value={title}
onChange={e => setTitle (e.target.value)}
className={inputClass (invalid)}/>)}
@@ -161,6 +270,7 @@ const PostSearchPage: FC = () => {
<FormField label="タグ">
{() => (
<TagInput
includeNico={true}
value={tagsStr}
setValue={setTagsStr}/>)}
</FormField>
@@ -296,15 +406,16 @@ const PostSearchPage: FC = () => {
</thead>
<tbody>
{results.map (row => (
<tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
<tr
key={row.id}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
<motion.div
layoutId={`page-${ row.id }`}
transition={{ type: 'spring',
stiffness: 500,
damping: 40,
mass: .5 }}>
layoutId={animationMode === 'off'
? undefined
: `page-${ row.id }`}
transition={thumbnailTransition}>
<img src={row.thumbnail || row.thumbnailBase || undefined}
alt={row.title || row.url}
title={row.title || row.url || undefined}
+2
ファイルの表示
@@ -6,6 +6,7 @@ import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({
@@ -27,6 +28,7 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue,
}))
+7 -2
ファイルの表示
@@ -17,8 +17,10 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api'
import { extractValidationError } from '@/lib/apiErrors'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchNicoTags } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, inputClass } from '@/lib/utils'
import { canEditContent } from '@/lib/users'
@@ -38,6 +40,8 @@ const setIf = (qs: URLSearchParams, key: string, value: string) => {
const NicoTagListPage: FC<Props> = ({ user }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const dialogue = useDialogue ()
const location = useLocation ()
const navigate = useNavigate ()
@@ -152,8 +156,9 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setLinkStatus (qLinkStatus)
setEditingId (null)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search, qLinkedTag, qLinkStatus, qName])
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search, qLinkedTag, qLinkStatus, qName])
useEffect (() => {
if (!(data))
+7 -2
ファイルの表示
@@ -11,8 +11,10 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { CATEGORY_NAMES } from '@/consts'
import { apiPut } from '@/lib/api'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTagChanges } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString } from '@/lib/utils'
import type { FC } from 'react'
@@ -43,6 +45,8 @@ const renderStateDiff = (diff: { current: string | null; prev: string | null })
const TagHistoryPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation ()
const query = new URLSearchParams (location.search)
const id = query.get ('id')
@@ -58,8 +62,9 @@ const TagHistoryPage: FC = () => {
const qc = useQueryClient ()
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search])
return (
<MainArea>
+48 -3
ファイルの表示
@@ -1,9 +1,10 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagListPage from '@/pages/tags/TagListPage'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const tagsApi = vi.hoisted (() => ({
fetchTags: vi.fn (),
@@ -12,6 +13,11 @@ const tagsApi = vi.hoisted (() => ({
vi.mock ('@/lib/tags', () => tagsApi)
describe ('TagListPage', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('loads tags from URL filters and renders the results table', async () => {
tagsApi.fetchTags.mockResolvedValueOnce ({
tags: [buildTag ({
@@ -38,6 +44,8 @@ describe ('TagListPage', () => {
name: '虹',
category: 'character',
page: 3,
limit: 20,
order: 'post_count:desc',
postCountGTE: 5,
deprecated: true,
}),
@@ -48,7 +56,7 @@ describe ('TagListPage', () => {
'/tags/7',
)
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1')
expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1')
expect (screen.getAllByText ('廃止')).toHaveLength (2)
})
@@ -58,7 +66,7 @@ describe ('TagListPage', () => {
renderWithProviders (<TagListPage/>, { route: '/tags' })
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
fireEvent.change (screen.getAllByRole ('combobox')[0], {
fireEvent.change (screen.getAllByRole ('combobox')[1], {
target: { value: 'character' },
})
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
@@ -82,4 +90,41 @@ describe ('TagListPage', () => {
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument ()
})
it ('uses URL limit before localStorage and preserves the query order', async () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { tagList: { limit: 100, order: 'name:asc' } } }))
tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [], count: 0 })
renderWithProviders (
<TagListPage/>,
{ route: '/tags?limit=50&order=updated_at%3Adesc' },
)
await waitFor (() => {
expect (tagsApi.fetchTags).toHaveBeenCalledWith (
expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }),
)
})
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
.toMatchObject ({ lists: { tagList: { limit: 50, order: 'updated_at:desc' } } })
})
it ('keeps the compact limit control outside the search form and resets page on change', async () => {
tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 })
renderWithProviders (<TagListPage/>, { route: '/tags?page=4' })
const limitSelect = await screen.findByLabelText ('件数')
expect (limitSelect.closest ('form')).toBeNull ()
fireEvent.change (limitSelect, { target: { value: '100' } })
await waitFor (() => {
expect (tagsApi.fetchTags).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
)
})
})
})
+109 -6
ファイルの表示
@@ -13,7 +13,18 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { tagsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_TAG_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchTags } from '@/lib/tags'
import { dateString, inputClass } from '@/lib/utils'
@@ -35,8 +46,35 @@ const boolFromQuery = (value: string | null): boolean =>
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const n = Number (value)
return n === 20 || n === 50 || n === 100 ? n : null
}
const parseOrder = (value: string | null): FetchTagsOrder | null => {
switch (value)
{
case 'name:asc':
case 'name:desc':
case 'category:asc':
case 'category:desc':
case 'post_count:asc':
case 'post_count:desc':
case 'created_at:asc':
case 'created_at:desc':
case 'updated_at:asc':
case 'updated_at:desc':
return value
default:
return null
}
}
const TagListPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation ()
const navigate = useNavigate ()
@@ -44,7 +82,11 @@ const TagListPage: FC = () => {
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('tagList')
?? DEFAULT_POST_LIST_LIMIT
)
const qName = query.get ('name') ?? ''
const qCategory = (query.get ('category') || null) as Category | null
@@ -58,7 +100,11 @@ const TagListPage: FC = () => {
const qDeprecated = query.has ('deprecated')
? boolFromQuery (query.get ('deprecated'))
: null
const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder
const order = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchTagsOrder> ('tagList')
?? DEFAULT_TAG_LIST_ORDER
)
const [name, setName] = useState ('')
const [category, setCategory] = useState<Category | null> (null)
@@ -88,6 +134,10 @@ const TagListPage: FC = () => {
const results = data?.tags ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('tagList', { limit, order })
}, [limit, order])
useEffect (() => {
setName (qName)
setCategory (qCategory)
@@ -99,9 +149,11 @@ const TagListPage: FC = () => {
setUpdatedTo (qUpdatedTo)
setDeprecated (qDeprecated)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE,
qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated])
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search, qCategory, qCreatedFrom, qCreatedTo,
qName, qPostCountGTE, qPostCountLTE, qUpdatedFrom, qUpdatedTo,
qDeprecated])
const handleSearch = (e: FormEvent) => {
e.preventDefault ()
@@ -121,16 +173,49 @@ const TagListPage: FC = () => {
qs.set ('deprecated', deprecated ? '1' : '0')
qs.set ('page', '1')
qs.set ('order', order)
qs.set ('limit', String (limit))
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const updateListQuery = (next: { limit?: 20 | 50 | 100
order?: FetchTagsOrder }) => {
const qs = new URLSearchParams (location.search)
qs.set ('limit', String (next.limit ?? limit))
qs.set ('order', next.order ?? order)
qs.set ('page', '1')
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const updatePage = (nextPage: number) => {
const qs = new URLSearchParams (location.search)
qs.set ('page', String (Math.max (1, nextPage)))
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const defaultDirection = { name: 'asc',
category: 'asc',
post_count: 'desc',
created_at: 'desc',
updated_at: 'desc' } as const
useKeyboardShortcuts (useMemo (() => ({
...(page > 1
? {
'pagination.previous': () => {
updatePage (page - 1)
},
}
: { }),
...(totalPages > 0 && page < totalPages
? {
'pagination.next': () => {
updatePage (Math.min (page + 1, totalPages))
},
}
: { }),
}), [page, totalPages, location.search]))
return (
<MainArea>
<Helmet>
@@ -138,7 +223,24 @@ const TagListPage: FC = () => {
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<select
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground">
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>
</label>
</div>
<form onSubmit={handleSearch} className="space-y-2">
{/* 名前 */}
@@ -146,6 +248,7 @@ const TagListPage: FC = () => {
{({ invalid }) => (
<input
type="text"
data-shortcut-focus-search="true"
value={name}
onChange={e => setName (e.target.value)}
className={inputClass (invalid)}/>)}
+1
ファイルの表示
@@ -41,6 +41,7 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue,
}))
vi.mock ('@/components/PostEmbed', () => ({
+26 -23
ファイルの表示
@@ -15,7 +15,16 @@ import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPost } from '@/lib/posts'
import {
getClientTheatreLayoutMode,
getClientTheatreTagFlow,
setClientTheatreLayoutMode,
setClientTheatreTagFlow,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -51,8 +60,6 @@ const INITIAL_THEATRE_INFO: TheatreInfo =
const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
{ tagPenalties: [], lightestPosts: [], heaviestPosts: [] }
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const MIN_MAIN_WIDTH = 360
const SIDEBAR_GAP_WIDTH = 16
@@ -208,6 +215,12 @@ type Props = { user: User | null }
const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const { id } = useParams ()
const dialogue = useDialogue ()
@@ -236,32 +249,20 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const [post, setPost] = useState<Post | null> (null)
const [videoLength, setVideoLength] = useState (0)
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => {
const stored = localStorage.getItem (LAYOUT_STORAGE_KEY)
return (
((['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[])
.includes (stored as TheatreLayoutMode))
? (stored as TheatreLayoutMode)
: 'threeColumns')
})
const [tagFlow, setTagFlow] = useState<TagFlow> (() => {
const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY)
return (
(['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow)
? (stored as TagFlow)
: 'vertical')
})
const [layoutMode, setLayoutMode] =
useState<TheatreLayoutMode> (() => getClientTheatreLayoutMode ())
const [tagFlow, setTagFlow] = useState<TagFlow> (() => getClientTheatreTagFlow ())
const { fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TheatreCommentField> ()
const changeLayoutMode = (mode: TheatreLayoutMode) => {
setLayoutMode (mode)
localStorage.setItem (LAYOUT_STORAGE_KEY, mode)
setClientTheatreLayoutMode (mode)
}
const changeTagFlow = (flow: TagFlow) => {
setTagFlow (flow)
localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow)
setClientTheatreTagFlow (flow)
}
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
@@ -626,7 +627,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
clearValidationErrors ()
await apiPost (`/theatres/${ id }/comments`, { content })
setContent ('')
commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' })
commentsRef.current?.scrollTo ({
top: 0,
behavior: clientScrollBehaviour (animationMode) })
}
catch (error)
{
@@ -843,8 +846,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return (
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: layoutTransition }}
className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950
md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
<Helmet>
@@ -868,7 +871,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</SidebarComponent>)}
<motion.main
layout="position"
layout={animationMode === 'off' ? false : 'position'}
className="order-1 min-w-0 flex-1 space-y-4 md:order-none
md:min-w-[360px] md:overflow-y-auto">
<div className="space-y-4">
+22 -8
ファイルの表示
@@ -6,6 +6,7 @@ import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
@@ -27,12 +28,23 @@ describe ('SettingPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
api.apiGet.mockImplementation ((path: string) => {
if (path === '/users/settings')
return Promise.resolve ({
theme: 'system',
auto_fetch_title: 'manual',
auto_fetch_thumbnail: 'manual',
wiki_editor_mode: 'split' })
if (path === '/users/theme_slots')
return Promise.resolve ([])
return Promise.resolve ({})
})
})
it ('shows loading when user is absent', () => {
renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>)
expect (screen.getByText ('Loading...')).toBeInTheDocument ()
expect (screen.getAllByText ('Loading...').length).toBeGreaterThan (0)
})
it ('updates the current user name', async () => {
@@ -42,8 +54,9 @@ describe ('SettingPage', () => {
renderWithProviders (<SettingPage user={user} setUser={setUser}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'new' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
const nameInput = (await screen.findAllByRole ('textbox'))[0]
fireEvent.change (nameInput, { target: { value: 'new' } })
fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0])
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
@@ -55,7 +68,7 @@ describe ('SettingPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('name')).toBe ('new')
expect (setUser).toHaveBeenCalled ()
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' })
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '表示名を更新しました.' })
})
it ('shows validation errors returned for the name field', async () => {
@@ -75,10 +88,11 @@ describe ('SettingPage', () => {
renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
const nameInput = (await screen.findAllByRole ('textbox'))[0]
fireEvent.change (nameInput, { target: { value: '' } })
fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0])
expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument ()
expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true')
expect ((await screen.findAllByText ('名前は必須です.')).length).toBeGreaterThan (0)
expect (nameInput).toHaveAttribute ('aria-invalid', 'true')
})
})
ファイル差分が大きすぎるため省略します 差分を読込み
+6
ファイルの表示
@@ -24,6 +24,11 @@ import type { FC } from 'react'
import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
// `wiki_editor_mode` is still kept in backend settings, but the common
// settings screen does not expose it at present. Until that UI returns, the
// editor stays on the fixed split view here.
type Props = { user: User | null }
@@ -114,6 +119,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
config={{ view: FIXED_WIKI_EDITOR_VIEW }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)}
</FormField>
+6
ファイルの表示
@@ -22,6 +22,11 @@ import 'react-markdown-editor-lite/lib/index.css'
import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
// `wiki_editor_mode` is still kept in backend settings, but the common
// settings screen does not expose it at present. Until that UI returns, the
// editor stays on the fixed split view here.
type Props = { user: User | null }
@@ -92,6 +97,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
config={{ view: FIXED_WIKI_EDITOR_VIEW }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)}
</FormField>
+13 -5
ファイルの表示
@@ -3,6 +3,10 @@ import { render } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter } from 'react-router-dom'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import type { ReactElement, ReactNode } from 'react'
type Options = {
@@ -15,21 +19,25 @@ export const renderWithProviders = (
) => {
const queryClient = new QueryClient ({
defaultOptions: {
queries: { retry: false },
},
queries: { retry: false } },
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<HelmetProvider>
<MemoryRouter initialEntries={[options.route ?? '/']}>
{children}
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</MemoryRouter>
</HelmetProvider>
</QueryClientProvider>)
return {
queryClient,
...render (ui, { wrapper: Wrapper }),
}
...render (ui, { wrapper: Wrapper }) }
}
+7
ファイルの表示
@@ -20,3 +20,10 @@ window.requestAnimationFrame = callback => {
callback (0)
return 0
}
Object.defineProperty (window, 'matchMedia', {
configurable: true,
value: vi.fn ().mockImplementation (() => ({
addEventListener: vi.fn (),
removeEventListener: vi.fn (),
matches: false })) })