From 4c0a4f5d9bbba5217f2971a55d013e93c6246371 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 27 Jun 2026 18:27:10 +0900 Subject: [PATCH] #306 --- AGENTS.md | 7 +- frontend/src/App.tsx | 1 - frontend/src/components/MaterialSidebar.tsx | 356 ++++++++++++------ frontend/src/components/TagLink.tsx | 17 +- frontend/src/components/TopNav.tsx | 45 ++- frontend/src/components/common/PageTitle.tsx | 6 +- frontend/src/lib/materials.ts | 41 +- .../pages/materials/MaterialDetailPage.tsx | 6 - .../pages/materials/MaterialHistoryPage.tsx | 26 +- .../src/pages/materials/MaterialListPage.tsx | 88 ++--- .../pages/materials/MaterialSearchPage.tsx | 51 --- 11 files changed, 350 insertions(+), 294 deletions(-) delete mode 100644 frontend/src/pages/materials/MaterialSearchPage.tsx diff --git a/AGENTS.md b/AGENTS.md index 908fc0f..23c8159 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,7 +271,12 @@ const value = - In TypeScript and TSX, convert every leading run of 8 spaces to a tab character. - A leading tab is exactly equivalent to 8 leading spaces. -- Never place a closing parenthesis at the beginning of a line. +- In TypeScript and TSX function declarations, including `const` arrow + function declarations, when the parameter list spans multiple lines, always + put the closing parenthesis at the beginning of its own line before the return + type or `=>`. +- In TypeScript and TSX, never place a closing parenthesis at the beginning of + a line except for a multi-line function declaration parameter list. - Never place a closing square bracket at the beginning of a line. - For object literals and other associative-array-style braces, do not place the closing brace at the beginning of a line. Function, lambda, callback, and diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac69d75..11f784c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -75,7 +75,6 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> - {/* }/> */} }/> }/> }/> diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index fa2943e..9767c63 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -1,23 +1,36 @@ -import { Fragment, useEffect, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import PrefetchLink from '@/components/PrefetchLink' -import TagLink from '@/components/TagLink' import SidebarComponent from '@/components/layout/SidebarComponent' -import { materialsKeys } from '@/lib/queryKeys' +import TagLink from '@/components/TagLink' import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials' +import { materialsKeys } from '@/lib/queryKeys' +import { cn } from '@/lib/utils' -import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' +import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types' -const FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] +const FILTERS: MaterialFilter[] = ['missing', 'present', 'any'] +const FILTER_LABELS: Record = { present: '有', missing: '無', 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 = ( @@ -37,24 +50,12 @@ const setChildrenById = ( const materialPath = ( tagId: number, - materialFilter: MaterialFilter): string => + materialFilter: MaterialFilter, +): string => `/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag` + `&material_filter=${ materialFilter }` -const clearTagSelectionPath = ( - locationSearch: string, - materialFilter: MaterialFilter): string => { - const qs = new URLSearchParams (locationSearch) - qs.delete ('tag_id') - qs.delete ('include_descendants') - qs.delete ('group_by') - qs.delete ('page') - qs.set ('material_filter', materialFilter) - return `/materials?${ qs.toString () }` -} - - const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ id: tag.id, name: tag.name, @@ -72,39 +73,42 @@ const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ const tagSelectionShellClass = (selected: boolean): string => - 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' + 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 () }` : '' }`) + 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 MaterialFilterButtons: FC<{ materialFilter: MaterialFilter + onChange: (materialFilter: MaterialFilter) => void }> = ( + { materialFilter, onChange }, +) => ( +
+ {FILTERS.map (value => ( - ))}
) @@ -132,10 +136,10 @@ const MaterialTreeNode: FC<{ }, [data, onChildren, open, tag.children.length, tag.id]) return ( - + <>
  • -
    -
    +
    +
    {tag.hasChildren && ( )}
    -
    -
    +
    +
    0 && (
      {tag.children.map (child => ( - ))}
    )} - ) + ) } -const MobileMaterialTreeNode: FC<{ - depth?: number - materialFilter: MaterialFilter - selectedTagId: number | null - onChildren: (tagId: number, children: MaterialSidebarTag[]) => void - openTags: Record - setOpenTags: Dispatch>> - tag: MaterialSidebarTag -}> = ({ depth = 0, materialFilter, onChildren, openTags, selectedTagId, - setOpenTags, tag }) => { +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 }), @@ -195,50 +218,155 @@ const MobileMaterialTreeNode: FC<{ 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 ( -
    -
    +
    +
    + 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}> + 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.hasChildren && ( )}
    {open && tag.children.length > 0 && (
    -
    )}
    ) } @@ -248,12 +376,14 @@ const MaterialSidebar: FC = () => { const location = useLocation () const navigate = useNavigate () const qs = new URLSearchParams (location.search) - const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'present') + 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 }), @@ -273,6 +403,29 @@ const MaterialSidebar: FC = () => { }) }, [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[]) => { @@ -288,8 +441,6 @@ const MaterialSidebar: FC = () => { updateMaterialFilterQuery (location.pathname, location.search, navigate, value) } - const clearTagSelection = clearTagSelectionPath (location.search, materialFilter) - const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => ( tags.map (tag => ( { return ( <>
    -
    - - {selectedTagId != null ? '選択解除' : '全素材'} - -
    + dark:text-stone-100 md:hidden flex h-[25dvh] min-h-0 flex-col + overflow-hidden"> -
    -
    +
    +
    {visibleRootTags.map (tag => ( {
    - - {selectedTagId != null ? '選択解除' : '全素材'} - diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 4abdf1a..a32dd4a 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -28,11 +28,12 @@ type Props = const TagLink: FC = ({ tag, - nestLevel = 0, - linkFlg = true, - withWiki = true, - withCount = true, - ...props }) => { + nestLevel = 0, + linkFlg = true, + withWiki = true, + withCount = true, + className, + ...props }) => { const spanClass = cn ( `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) @@ -105,7 +106,7 @@ const TagLink: FC = ({ tag, )} {tag.matchedAlias != null && ( <> - + {tag.matchedAlias} <> → @@ -114,12 +115,12 @@ const TagLink: FC = ({ tag, ? ( {tag.name} ) : ( - {tag.name} )} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index ed2f596..dccd9ee 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -7,30 +7,36 @@ import Separator from '@/components/MenuSeparator' import PrefetchLink from '@/components/PrefetchLink' import TopNavUser from '@/components/TopNavUser' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' -import { tagsKeys, wikiKeys } from '@/lib/queryKeys' +import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { fetchTag, fetchTagByName } from '@/lib/tags' +import { fetchMaterial } from '@/lib/materials' import { cn } from '@/lib/utils' import { fetchWikiPage } from '@/lib/wiki' import type { FC, MouseEvent } from 'react' -import type { Menu, MenuVisibleItem, Tag, User } from '@/types' +import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types' type Props = { user: User | null } -export const menuOutline = ({ tag, wikiId, user, pathName }: { - tag?: Tag | null - wikiId: number | null - user: User | null, - pathName: string }): Menu => { - const postCount = tag?.postCount ?? 0 +export const menuOutline = ( + { tag, material, wikiId, user, pathName }: { + tag?: Tag | null + material?: Material | null + wikiId: number | null + user: User | null, + pathName: string }, +): Menu => { + const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0 const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId) const wikiTitle = pathName.split ('/')[2] ?? '' const tagFlg = /^\/tags\/\d+/.test (pathName) + const materialFlg = /^\/materials\/\d+/.test (pathName) + return [ { name: '広場', to: '/posts', subMenu: [ { name: '一覧', to: '/posts' }, @@ -51,10 +57,15 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { visible: tagFlg && tag?.category !== 'nico' }] }, { name: '素材', to: '/materials', visible: false, subMenu: [ { name: '一覧', to: '/materials' }, - { name: '検索', to: '/materials/search', visible: false }, { name: '追加', to: '/materials/new' }, - { name: '全体履歴', to: '/materials/changes', visible: false }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, + { name: '全体履歴', to: '/materials/changes' }, + { name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' }, + { component: , visible: materialFlg }, + { name: `広場 (${ postCount || 0 })`, + to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`, + visible: materialFlg }, + { name: '履歴', to: `/materials/changes?material_id=${ material?.id }`, + visible: materialFlg }] }, { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: '検索', to: '/wiki' }, { name: '新規', to: '/wiki/new' }, @@ -119,15 +130,23 @@ const TopNav: FC = ({ user }) => { queryFn: () => fetchWikiPage (wikiIdStr, { }) }) const tagFlg = /^\/tags\/\d+/.test (location.pathname) - const effectiveTitle = (tagFlg ? location.pathname.split ('/')[2] : wikiPage?.title) ?? '' + const materialFlg = /^\/materials\/\d+/.test (location.pathname) + const effectiveTitle = (((tagFlg || materialFlg) + ? location.pathname.split ('/')[2] + : wikiPage?.title) + ?? '') const { data: tag } = useQuery ({ enabled: Boolean (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle), queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) }) + const { data: material } = useQuery ({ + enabled: Boolean (effectiveTitle), + queryKey: materialsKeys.show (effectiveTitle), + queryFn: () => fetchMaterial (effectiveTitle) }) - const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) + const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname }) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) const moreMenu = menu.filter (item => !(item.visible ?? true) diff --git a/frontend/src/components/common/PageTitle.tsx b/frontend/src/components/common/PageTitle.tsx index 5b8a697..e692ba6 100644 --- a/frontend/src/components/common/PageTitle.tsx +++ b/frontend/src/components/common/PageTitle.tsx @@ -1,12 +1,14 @@ import React from 'react' +import { cn } from '@/lib/utils' + import type { FC } from 'react' type Props = { children: React.ReactNode } -const PageTitle: FC = ({ children }) => ( -

    +const PageTitle: FC = ({ children, className, ...rest }) => ( +

    {children}

    ) diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts index 2acfd84..ab9e044 100644 --- a/frontend/src/lib/materials.ts +++ b/frontend/src/lib/materials.ts @@ -1,41 +1,31 @@ -import { - apiGet, - isApiError, - apiPost, - apiPut, -} from '@/lib/api' +import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api' -import type { - Material, - MaterialIndexResponse, - MaterialVersion, - FetchMaterialsParams, - MaterialFilter, - MaterialSyncSuppression, - MaterialSidebarTag, - MaterialTagTree, -} from '@/types' +import type { Material, + MaterialIndexResponse, + MaterialVersion, + FetchMaterialsParams, + MaterialFilter, + MaterialSyncSuppression, + MaterialSidebarTag, + MaterialTagTree } from '@/types' export type FetchMaterialTreeParams = { parentId?: number | null - materialFilter: MaterialFilter -} + materialFilter: MaterialFilter } -export type MaterialSyncSuppressionResponse = { - suppressions: MaterialSyncSuppression[] -} +export type MaterialSyncSuppressionResponse = { suppressions: MaterialSyncSuppression[] } export type MaterialChangesResponse = { versions: MaterialVersion[] - count: number -} + count: number } const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] export const parseMaterialFilter = ( value: unknown, - fallback: MaterialFilter = 'present'): MaterialFilter => + fallback: MaterialFilter = 'present', +): MaterialFilter => typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter) ? value as MaterialFilter : fallback @@ -45,7 +35,8 @@ export const fetchMaterials = async ( { q, tagState, mediaKind, createdFrom, createdTo, updatedFrom, updatedTo, sort, direction, page, tagId, includeDescendants, groupBy, - limit }: FetchMaterialsParams): Promise => + limit }: FetchMaterialsParams, +): Promise => await apiGet ('/materials', { params: { ...(q && { q }), tag_state: tagState, diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index 18b9664..c25791a 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -119,12 +119,6 @@ const MaterialDetailPage: FC = () => { : materialTitle} - - この素材の履歴 - - {(material.file && material.contentType) && ( (/image\/.*/.test (material.contentType) && ( {material.tag?.name)) diff --git a/frontend/src/pages/materials/MaterialHistoryPage.tsx b/frontend/src/pages/materials/MaterialHistoryPage.tsx index 5bc490a..82876db 100644 --- a/frontend/src/pages/materials/MaterialHistoryPage.tsx +++ b/frontend/src/pages/materials/MaterialHistoryPage.tsx @@ -80,14 +80,7 @@ const MaterialHistoryPage: FC = () => {
    -
    - 素材履歴 - - 素材一覧へ戻る - -
    + 素材履歴
    { onChange={e => setTagInput (e.target.value)} className={inputClass (invalid)}/>)} - - - {({ invalid }) => ( - )} -