523 行
16 KiB
TypeScript
523 行
16 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { useLocation } from 'react-router-dom'
|
|
|
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
|
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 { useUITranslation } from '@/lib/useUITranslation'
|
|
|
|
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
|
|
|
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
|
|
|
|
const FILTERS: MaterialFilter[] = ['missing', 'present', '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 useLocaleNavigate>,
|
|
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 },
|
|
) => {
|
|
const { label } = useUITranslation ('materials')
|
|
return (
|
|
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
|
|
<label className="my-auto text-sm font-bold">{label ('sidebar.label')}</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']))}>
|
|
{label (`sidebar.${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 locale = useLocale ()
|
|
const open = Boolean (openTags[tag.id])
|
|
const { data } = useQuery ({
|
|
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
|
|
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
|
|
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 locale = useLocale ()
|
|
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 }, locale),
|
|
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
|
|
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 { label } = useUITranslation ('materials')
|
|
const locale = useLocale ()
|
|
const location = useLocation ()
|
|
const navigate = useLocaleNavigate ()
|
|
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 }, locale),
|
|
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }, locale)})
|
|
|
|
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">
|
|
{label ('shared:messages.loadingEllipsis')}
|
|
</p>)}
|
|
{isError && (
|
|
<p className="text-sm text-red-600 dark:text-red-300">
|
|
{label ('sidebar.error')}
|
|
</p>)}
|
|
{(!isLoading && !isError) && (
|
|
<ul>
|
|
{renderDesktopTree (visibleRootTags)}
|
|
</ul>)}
|
|
</div>
|
|
</SidebarComponent>
|
|
</>)
|
|
}
|
|
|
|
|
|
export default MaterialSidebar
|