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 = { 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, 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 }, ) => (
{FILTERS.map (value => ( ))}
) const MaterialTreeNode: FC<{ materialFilter: MaterialFilter selectedTagId: number | null nestLevel?: number onChildren: (tagId: number, children: MaterialSidebarTag[]) => void openTags: Record setOpenTags: Dispatch>> 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 ( <>
  • {tag.hasChildren && ( )}
  • {open && tag.children.length > 0 && (
      {tag.children.map (child => ( ))}
    )} ) } const MobileMaterialTreeNode: FC<{ depth?: number availableInlineSizePx?: number | null materialFilter: MaterialFilter selectedTagId: number | null onChildren: (tagId: number, children: MaterialSidebarTag[]) => void openTags: Record setOpenTags: Dispatch>> tag: MaterialSidebarTag }> = ( { depth = 0, availableInlineSizePx = null, materialFilter, onChildren, openTags, selectedTagId, setOpenTags, tag, }, ) => { const open = Boolean (openTags[tag.id]) const tagColumnRef = useRef (null) const chipRef = useRef (null) const buttonRef = useRef (null) const expansionSlotRef = useRef (null) const expansionBorderRef = useRef (null) const [tagChipInlineSizePx, setTagChipInlineSizePx] = useState (null) const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState (null) const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState (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 (
    {tag.hasChildren && ( )}
    {open && tag.children.length > 0 && (
    )}
    ) } 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 ([]) const [openTags, setOpenTags] = useState> ({ }) const mobileRailRef = useRef (null) const [mobileAvailableInlineSizePx, setMobileAvailableInlineSizePx] = useState (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 => ( ))) return ( <>
    {visibleRootTags.map (tag => ( ))}
    {isLoading && (

    読込中……

    )} {isError && (

    タグ一覧の取得に失敗しました.

    )} {(!isLoading && !isError) && (
      {renderDesktopTree (visibleRootTags)}
    )}
    ) } export default MaterialSidebar