15619886f2
Reviewed-on: #396 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
513 行
16 KiB
TypeScript
513 行
16 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { useLocation, useNavigate } from 'react-router-dom'
|
||
|
||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||
import TagLink from '@/components/TagLink'
|
||
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
|
||
import { materialsKeys } from '@/lib/queryKeys'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||
|
||
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
|
||
|
||
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
|
||
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
|
||
|
||
|
||
const px = (value: string): number => {
|
||
const parsed = Number.parseFloat (value)
|
||
return Number.isFinite (parsed) ? parsed : 0
|
||
}
|
||
|
||
|
||
const verticalChrome = (el: HTMLElement): number => {
|
||
const style = window.getComputedStyle (el)
|
||
|
||
return (
|
||
px (style.paddingTop)
|
||
+ px (style.paddingBottom)
|
||
+ px (style.borderTopWidth)
|
||
+ px (style.borderBottomWidth))
|
||
}
|
||
|
||
|
||
const setChildrenById = (
|
||
tags: MaterialSidebarTag[],
|
||
targetId: number,
|
||
children: MaterialSidebarTag[],
|
||
): MaterialSidebarTag[] => (
|
||
tags.map (tag => {
|
||
if (tag.id === targetId)
|
||
return { ...tag, children }
|
||
|
||
if (tag.children.length === 0)
|
||
return tag
|
||
|
||
return { ...tag, children: setChildrenById (tag.children, targetId, children) }
|
||
}))
|
||
|
||
|
||
const materialPath = (
|
||
tagId: number,
|
||
materialFilter: MaterialFilter,
|
||
): string =>
|
||
`/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag`
|
||
+ `&material_filter=${ materialFilter }`
|
||
|
||
|
||
const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
|
||
id: tag.id,
|
||
name: tag.name,
|
||
category: tag.category,
|
||
deprecatedAt: tag.deprecated ? '' : null,
|
||
aliases: [],
|
||
parents: [],
|
||
postCount: 0,
|
||
createdAt: '',
|
||
updatedAt: '',
|
||
hasWiki: false,
|
||
materialId: null,
|
||
hasDeerjikists: false,
|
||
matchedAlias: null })
|
||
|
||
|
||
const tagSelectionShellClass = (selected: boolean): string =>
|
||
cn (selected
|
||
? ['rounded-md border border-sky-500 bg-sky-50 px-2 py-1 text-sky-700',
|
||
'dark:border-sky-400 dark:bg-sky-950 dark:text-sky-100']
|
||
: 'px-2 py-1')
|
||
|
||
|
||
const updateMaterialFilterQuery = (
|
||
pathname: string,
|
||
locationSearch: string,
|
||
navigate: ReturnType<typeof useNavigate>,
|
||
materialFilter: MaterialFilter,
|
||
) => {
|
||
const qs = new URLSearchParams (locationSearch)
|
||
qs.set ('material_filter', materialFilter)
|
||
navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`)
|
||
}
|
||
|
||
|
||
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
|
||
onChange: (materialFilter: MaterialFilter) => void }> = (
|
||
{ materialFilter, onChange },
|
||
) => (
|
||
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
|
||
<label className="my-auto text-sm font-bold">素材:</label>
|
||
{FILTERS.map (value => (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
onClick={() => onChange (value)}
|
||
className={cn (
|
||
'rounded-full border px-3 py-1 text-sm',
|
||
(materialFilter === value
|
||
? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||
'dark:bg-sky-950 dark:text-sky-100']
|
||
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
|
||
'dark:bg-stone-900 dark:text-stone-200']))}>
|
||
{FILTER_LABELS[value]}
|
||
</button>))}
|
||
</div>)
|
||
|
||
|
||
const MaterialTreeNode: FC<{
|
||
materialFilter: MaterialFilter
|
||
selectedTagId: number | null
|
||
nestLevel?: number
|
||
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
|
||
openTags: Record<number, boolean>
|
||
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
|
||
tag: MaterialSidebarTag
|
||
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
|
||
setOpenTags, tag }) => {
|
||
const open = Boolean (openTags[tag.id])
|
||
const { data } = useQuery ({
|
||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||
|
||
useEffect (() => {
|
||
if (open && data && tag.children.length === 0)
|
||
onChildren (tag.id, data)
|
||
}, [data, onChildren, open, tag.children.length, tag.id])
|
||
|
||
return (
|
||
<>
|
||
<li>
|
||
<div className="flex flex-center">
|
||
<div className="flex-none w-4 my-auto">
|
||
{tag.hasChildren && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
|
||
className="text-neutral-500 dark:text-stone-400">
|
||
{open ? <>−</> : '+'}
|
||
</button>)}
|
||
</div>
|
||
<div className="min-w-0 flex-1 my-auto">
|
||
<div className={cn (tagSelectionShellClass (selectedTagId === tag.id),
|
||
'min-w-0 truncate')}>
|
||
<TagLink
|
||
tag={sidebarTagToTag (tag)}
|
||
nestLevel={nestLevel}
|
||
title={tag.name}
|
||
withCount={false}
|
||
withWiki={false}
|
||
to={materialPath (tag.id, materialFilter)}/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
{open && tag.children.length > 0 && (
|
||
<ul>
|
||
{tag.children.map (child => (
|
||
<MaterialTreeNode
|
||
key={child.id}
|
||
tag={child}
|
||
nestLevel={nestLevel + 1}
|
||
materialFilter={materialFilter}
|
||
selectedTagId={selectedTagId}
|
||
openTags={openTags}
|
||
setOpenTags={setOpenTags}
|
||
onChildren={onChildren}/>))}
|
||
</ul>)}
|
||
</>)
|
||
}
|
||
|
||
|
||
const MobileMaterialTreeNode: FC<{ depth?: number
|
||
availableInlineSizePx?: number | null
|
||
materialFilter: MaterialFilter
|
||
selectedTagId: number | null
|
||
onChildren: (tagId: number, children: MaterialSidebarTag[]) =>
|
||
void
|
||
openTags: Record<number, boolean>
|
||
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
|
||
tag: MaterialSidebarTag }> = (
|
||
{
|
||
depth = 0,
|
||
availableInlineSizePx = null,
|
||
materialFilter,
|
||
onChildren,
|
||
openTags,
|
||
selectedTagId,
|
||
setOpenTags,
|
||
tag,
|
||
},
|
||
) => {
|
||
const open = Boolean (openTags[tag.id])
|
||
const tagColumnRef = useRef<HTMLDivElement | null> (null)
|
||
const chipRef = useRef<HTMLDivElement | null> (null)
|
||
const buttonRef = useRef<HTMLButtonElement | null> (null)
|
||
const expansionSlotRef = useRef<HTMLDivElement | null> (null)
|
||
const expansionBorderRef = useRef<HTMLDivElement | null> (null)
|
||
const [tagChipInlineSizePx, setTagChipInlineSizePx] = useState<number | null> (null)
|
||
const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState<number | null> (null)
|
||
const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
|
||
const { data } = useQuery ({
|
||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||
|
||
useEffect (() => {
|
||
if (open && data && tag.children.length === 0)
|
||
onChildren (tag.id, data)
|
||
}, [data, onChildren, open, tag.children.length, tag.id])
|
||
|
||
useEffect (() => {
|
||
const tagColumn = tagColumnRef.current
|
||
const chip = chipRef.current
|
||
if (!(tagColumn) || !(chip))
|
||
return
|
||
|
||
const updateTagInlineSize = () => {
|
||
const buttonHeight = buttonRef.current?.offsetHeight ?? 0
|
||
const gap = tag.hasChildren ? px (window.getComputedStyle (tagColumn).rowGap) : 0
|
||
const columnHeight =
|
||
availableInlineSizePx == null
|
||
? tagColumn.clientHeight
|
||
: Math.min (tagColumn.clientHeight, availableInlineSizePx)
|
||
const nextChipInlineSize = Math.max (24, columnHeight - buttonHeight - gap)
|
||
const nextLinkInlineSize = Math.max (
|
||
16,
|
||
nextChipInlineSize - verticalChrome (chip),
|
||
)
|
||
|
||
setTagChipInlineSizePx (prev => prev === nextChipInlineSize ? prev : nextChipInlineSize)
|
||
setTagLinkInlineSizePx (prev => prev === nextLinkInlineSize ? prev : nextLinkInlineSize)
|
||
}
|
||
|
||
updateTagInlineSize ()
|
||
|
||
const resizeObserver = new ResizeObserver (() => {
|
||
updateTagInlineSize ()
|
||
})
|
||
|
||
resizeObserver.observe (tagColumn)
|
||
resizeObserver.observe (chip)
|
||
|
||
if (buttonRef.current)
|
||
resizeObserver.observe (buttonRef.current)
|
||
|
||
return () => {
|
||
resizeObserver.disconnect ()
|
||
}
|
||
}, [availableInlineSizePx, open, tag.hasChildren, tag.children.length])
|
||
|
||
useEffect (() => {
|
||
const expansionSlot = expansionSlotRef.current
|
||
const expansionBorder = expansionBorderRef.current
|
||
if (!(expansionSlot) || !(expansionBorder))
|
||
return
|
||
|
||
const updateChildInlineSize = () => {
|
||
const base =
|
||
availableInlineSizePx == null
|
||
? expansionSlot.clientHeight
|
||
: availableInlineSizePx
|
||
const chrome = verticalChrome (expansionSlot) + verticalChrome (expansionBorder)
|
||
const nextInlineSize = Math.max (24, base - chrome)
|
||
setChildAvailableInlineSizePx (prev => prev === nextInlineSize ? prev : nextInlineSize)
|
||
}
|
||
|
||
updateChildInlineSize ()
|
||
|
||
const resizeObserver = new ResizeObserver (() => {
|
||
updateChildInlineSize ()
|
||
})
|
||
|
||
resizeObserver.observe (expansionSlot)
|
||
resizeObserver.observe (expansionBorder)
|
||
|
||
return () => {
|
||
resizeObserver.disconnect ()
|
||
}
|
||
}, [availableInlineSizePx, open, tag.children.length])
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 max-h-full flex-row-reverse items-start gap-2
|
||
overflow-hidden">
|
||
<div
|
||
ref={tagColumnRef}
|
||
className="flex h-full min-h-0 max-h-full flex-col items-center gap-1
|
||
overflow-hidden">
|
||
<div
|
||
ref={chipRef}
|
||
className={cn (
|
||
tagSelectionShellClass (selectedTagId === tag.id),
|
||
'box-border rounded-xl border px-3 py-2 text-sm shadow-sm',
|
||
'min-h-0 overflow-hidden [max-inline-size:var(--tag-chip-inline-size)]',
|
||
'[max-height:var(--tag-chip-inline-size)]',
|
||
)}
|
||
style={{
|
||
writingMode: 'vertical-rl',
|
||
'--tag-chip-inline-size': (
|
||
tagChipInlineSizePx == null
|
||
? undefined
|
||
: `${ tagChipInlineSizePx }px`),
|
||
'--tag-link-inline-size': (
|
||
tagLinkInlineSizePx == null
|
||
? undefined
|
||
: `${ tagLinkInlineSizePx }px`),
|
||
} as CSSProperties}>
|
||
<TagLink
|
||
tag={sidebarTagToTag (tag)}
|
||
title={tag.name}
|
||
truncateOnMobile
|
||
withCount={false}
|
||
withWiki={false}
|
||
to={materialPath (tag.id, materialFilter)}
|
||
className="block overflow-hidden text-ellipsis whitespace-nowrap
|
||
[max-inline-size:var(--tag-link-inline-size)]
|
||
[max-height:var(--tag-link-inline-size)]
|
||
[&_.tag-marquee]:block
|
||
[&_.tag-marquee]:h-full
|
||
[&_.tag-marquee]:overflow-hidden
|
||
[&_.tag-marquee__static]:h-full
|
||
[&_.tag-marquee__static]:overflow-hidden
|
||
[&_.tag-marquee__static]:text-ellipsis
|
||
[&_.tag-marquee__static]:[text-overflow:ellipsis]"/>
|
||
</div>
|
||
{tag.hasChildren && (
|
||
<button
|
||
ref={buttonRef}
|
||
type="button"
|
||
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
|
||
className="flex-none rounded-full border border-stone-300 bg-white
|
||
px-2 py-0.5 text-sm text-stone-700 dark:border-stone-700
|
||
dark:bg-stone-900 dark:text-stone-100">
|
||
{open ? <>−</> : '+'}
|
||
</button>)}
|
||
</div>
|
||
{open && tag.children.length > 0 && (
|
||
<div
|
||
ref={expansionSlotRef}
|
||
className={cn (
|
||
'h-full min-h-0 max-h-full overflow-hidden box-border',
|
||
depth === 0 ? 'pt-5' : 'pt-3')}>
|
||
<div
|
||
ref={expansionBorderRef}
|
||
className="relative max-h-full overflow-hidden rounded-2xl border border-stone-200
|
||
bg-stone-100/70 py-2 pl-2 pr-2 text-stone-900
|
||
dark:border-stone-700 dark:bg-stone-900/70 dark:text-stone-100">
|
||
<div className="flex h-full min-h-0 max-h-full flex-row-reverse items-start gap-2
|
||
overflow-hidden">
|
||
<span
|
||
aria-hidden="true"
|
||
className="absolute -right-3 top-4 h-px w-3 bg-stone-300 dark:bg-stone-600"/>
|
||
{tag.children.map (child => (
|
||
<div
|
||
key={child.id}
|
||
className="relative h-full min-h-0 max-h-full overflow-hidden">
|
||
<MobileMaterialTreeNode
|
||
tag={child}
|
||
depth={depth + 1}
|
||
availableInlineSizePx={childAvailableInlineSizePx}
|
||
materialFilter={materialFilter}
|
||
selectedTagId={selectedTagId}
|
||
openTags={openTags}
|
||
setOpenTags={setOpenTags}
|
||
onChildren={onChildren}/>
|
||
</div>))}
|
||
</div>
|
||
</div>
|
||
</div>)}
|
||
</div>)
|
||
}
|
||
|
||
|
||
const MaterialSidebar: FC = () => {
|
||
const location = useLocation ()
|
||
const navigate = useNavigate ()
|
||
const qs = new URLSearchParams (location.search)
|
||
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
|
||
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
|
||
|
||
const [desktopTags, setDesktopTags] = useState<MaterialSidebarTag[]> ([])
|
||
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
|
||
const mobileRailRef = useRef<HTMLDivElement | null> (null)
|
||
const [mobileAvailableInlineSizePx, setMobileAvailableInlineSizePx] =
|
||
useState<number | null> (null)
|
||
|
||
const { data: rootTags = [], isLoading, isError } = useQuery ({
|
||
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
|
||
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
|
||
|
||
useEffect (() => {
|
||
setDesktopTags (rootTags)
|
||
}, [rootTags])
|
||
|
||
useEffect (() => {
|
||
const el = mobileRailRef.current
|
||
if (!(el))
|
||
return
|
||
|
||
requestAnimationFrame (() => {
|
||
el.scrollLeft = el.scrollWidth
|
||
})
|
||
}, [rootTags, materialFilter])
|
||
|
||
useEffect (() => {
|
||
const el = mobileRailRef.current
|
||
if (!(el))
|
||
return
|
||
|
||
const updateAvailableInlineSize = () => {
|
||
const nextInlineSize = Math.max (24, el.clientHeight)
|
||
setMobileAvailableInlineSizePx (prev => prev === nextInlineSize ? prev : nextInlineSize)
|
||
}
|
||
|
||
updateAvailableInlineSize ()
|
||
|
||
const resizeObserver = new ResizeObserver (() => {
|
||
updateAvailableInlineSize ()
|
||
})
|
||
|
||
resizeObserver.observe (el)
|
||
|
||
return () => {
|
||
resizeObserver.disconnect ()
|
||
}
|
||
}, [rootTags, materialFilter])
|
||
|
||
const visibleRootTags = desktopTags.length > 0 ? desktopTags : rootTags
|
||
|
||
const setChildren = (tagId: number, children: MaterialSidebarTag[]) => {
|
||
setDesktopTags (prev => {
|
||
const base = prev.length > 0 ? prev : rootTags
|
||
return setChildrenById (base, tagId, children)
|
||
})
|
||
}
|
||
|
||
const handleFilterChange = (value: MaterialFilter) => {
|
||
setDesktopTags ([])
|
||
setOpenTags ({ })
|
||
updateMaterialFilterQuery (location.pathname, location.search, navigate, value)
|
||
}
|
||
|
||
const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => (
|
||
tags.map (tag => (
|
||
<MaterialTreeNode
|
||
key={tag.id}
|
||
tag={tag}
|
||
materialFilter={materialFilter}
|
||
selectedTagId={selectedTagId}
|
||
openTags={openTags}
|
||
setOpenTags={setOpenTags}
|
||
onChildren={setChildren}/>)))
|
||
|
||
return (
|
||
<>
|
||
<div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950
|
||
dark:text-stone-100 md:hidden flex h-[25dvh] min-h-0 flex-col
|
||
overflow-hidden">
|
||
<MaterialFilterButtons
|
||
materialFilter={materialFilter}
|
||
onChange={handleFilterChange}/>
|
||
<div
|
||
ref={mobileRailRef}
|
||
className="mt-3 min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
|
||
<div className="flex min-w-max flex-row-reverse items-start gap-3 pb-1 h-full
|
||
min-h-0 max-h-full">
|
||
{visibleRootTags.map (tag => (
|
||
<MobileMaterialTreeNode
|
||
key={tag.id}
|
||
tag={tag}
|
||
availableInlineSizePx={mobileAvailableInlineSizePx}
|
||
materialFilter={materialFilter}
|
||
selectedTagId={selectedTagId}
|
||
openTags={openTags}
|
||
setOpenTags={setOpenTags}
|
||
onChildren={setChildren}/>))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<SidebarComponent sidebarKey="materials" className="hidden md:block">
|
||
<div className="space-y-4">
|
||
<MaterialFilterButtons
|
||
materialFilter={materialFilter}
|
||
onChange={handleFilterChange}/>
|
||
{isLoading && (
|
||
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)}
|
||
{isError && (
|
||
<p className="text-sm text-red-600 dark:text-red-300">
|
||
タグ一覧の取得に失敗しました.
|
||
</p>)}
|
||
{(!isLoading && !isError) && (
|
||
<ul>
|
||
{renderDesktopTree (visibleRootTags)}
|
||
</ul>)}
|
||
</div>
|
||
</SidebarComponent>
|
||
</>)
|
||
}
|
||
|
||
|
||
export default MaterialSidebar
|