From ce28661271eda84449dd22375577f2f742c1b6bf Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 26 Jun 2026 01:56:01 +0900 Subject: [PATCH] #306 --- .../app/controllers/materials_controller.rb | 167 +++++++++++++- frontend/src/components/MaterialSidebar.tsx | 107 ++++++--- frontend/src/lib/materials.ts | 10 +- .../src/pages/materials/MaterialListPage.tsx | 205 ++++++++++++++++-- .../src/pages/materials/MaterialNewPage.tsx | 3 +- frontend/src/types.ts | 31 +++ 6 files changed, 471 insertions(+), 52 deletions(-) diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index ad0e025..ffc4e6d 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -1,3 +1,6 @@ +require 'set' + + class MaterialsController < ApplicationController rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path @@ -13,12 +16,14 @@ class MaterialsController < ApplicationController offset = (page - 1) * limit filters = material_index_filters + tag_graph = material_index_tag_graph(filters) q = Material.includes(:material_export_items, thumbnail_attachment: :blob, file_attachment: :blob, tag: :tag_name) q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged' q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged' + q = apply_material_tag_filter(q, filters, tag_graph) q = q.where('materials.created_at >= ?', filters[:created_from]) if filters[:created_from] q = q.where('materials.created_at <= ?', filters[:created_to]) if filters[:created_to] q = q.where('materials.updated_at >= ?', filters[:updated_from]) if filters[:updated_from] @@ -37,8 +42,16 @@ class MaterialsController < ApplicationController .offset(offset) .to_a - render json: { materials: MaterialRepr.list_many(materials, host: request.base_url), - count: } + response = { materials: MaterialRepr.list_many(materials, host: request.base_url), + count: } + if filters[:tag_id] + response[:tag_scope] = material_index_tag_scope(filters, tag_graph) + end + if filters[:group_by] == 'parent_tag' + response[:groups] = material_index_groups(materials, tag_graph) + end + + render json: response end def show @@ -198,9 +211,18 @@ class MaterialsController < ApplicationController direction = params[:direction].to_s.downcase direction = 'desc' unless ['asc', 'desc'].include?(direction) + group_by = params[:group_by].to_s.presence + group_by = 'none' unless ['none', 'parent_tag'].include?(group_by) + + tag_id = params[:tag_id].to_i + tag_id = nil if tag_id <= 0 + { q: params[:q].to_s.strip.presence, tag_state:, media_kind:, + tag_id:, + include_descendants: bool?(:include_descendants), + group_by:, created_from: parse_time_param(:created_from), created_to: parse_time_param(:created_to), updated_from: parse_time_param(:updated_from), @@ -270,6 +292,147 @@ class MaterialsController < ApplicationController end end + def apply_material_tag_filter q, filters, tag_graph + return q unless filters[:tag_id] + + tag_ids = + if tag_graph + tag_graph[:scope_tag_ids] + else + [filters[:tag_id]] + end + + q.where(tag_id: tag_ids) + end + + def material_index_tag_graph filters + return nil unless filters[:tag_id] + + children_by_parent_id = Hash.new { |h, k| h[k] = [] } + TagImplication.pluck(:parent_tag_id, :tag_id).each do |parent_id, child_id| + children_by_parent_id[parent_id] << child_id + end + + scope_tag_ids = + if filters[:include_descendants] + collect_material_descendant_ids(filters[:tag_id], children_by_parent_id) + else + Set.new([filters[:tag_id]]) + end + tags_by_id = + Tag + .joins(:tag_name) + .where(id: scope_tag_ids) + .pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at') + .each_with_object({ }) do |(id, name, category, deprecated_at), hash| + hash[id] = { id:, name:, category:, deprecated: deprecated_at.present? } + end + + selected_tag = tags_by_id[filters[:tag_id]] + return nil unless selected_tag + + group_tag_id_by_tag_id = { } + children_by_parent_id[filters[:tag_id]].each do |child_tag_id| + assign_material_group_branch(child_tag_id, + children_by_parent_id:, + tags_by_id:, + group_tag_id_by_tag_id:, + current_group_tag_id: nil, + seen: Set.new([filters[:tag_id]])) + end + + { selected_tag_id: filters[:tag_id], + selected_tag:, + scope_tag_ids: scope_tag_ids.to_a, + tags_by_id:, + group_tag_id_by_tag_id: } + end + + def collect_material_descendant_ids selected_tag_id, children_by_parent_id + ids = Set.new([selected_tag_id]) + stack = [selected_tag_id] + + until stack.empty? + tag_id = stack.pop + children_by_parent_id[tag_id].each do |child_tag_id| + next if ids.include?(child_tag_id) + + ids << child_tag_id + stack << child_tag_id + end + end + + ids + end + + def assign_material_group_branch tag_id, children_by_parent_id:, tags_by_id:, + group_tag_id_by_tag_id:, current_group_tag_id:, seen: + return if seen.include?(tag_id) + + tag = tags_by_id[tag_id] + return unless tag + + seen = seen.dup << tag_id + next_group_tag_id = current_group_tag_id + next_group_tag_id = tag_id if next_group_tag_id.nil? && !tag[:deprecated] + group_tag_id_by_tag_id[tag_id] = next_group_tag_id if next_group_tag_id + + children_by_parent_id[tag_id].each do |child_tag_id| + assign_material_group_branch(child_tag_id, + children_by_parent_id:, + tags_by_id:, + group_tag_id_by_tag_id:, + current_group_tag_id: next_group_tag_id, + seen:) + end + end + + def material_index_groups materials, tag_graph + return [] unless tag_graph + + groups_by_key = { } + + materials.each do |material| + next unless material.tag_id + + group_tag_id = + if material.tag_id == tag_graph[:selected_tag_id] + tag_graph[:selected_tag_id] + else + tag_graph[:group_tag_id_by_tag_id][material.tag_id] || tag_graph[:selected_tag_id] + end + + group_tag = tag_graph[:tags_by_id][group_tag_id] + next unless group_tag + + key = "tag:#{ group_tag_id }" + group = + groups_by_key[key] ||= { key:, + tag: group_tag_repr(group_tag), + material_ids: [], + count: 0 } + group[:material_ids] << material.id + group[:count] += 1 + end + + groups_by_key.values.sort_by { |group| group[:tag][:name] } + end + + def material_index_tag_scope filters, tag_graph + return nil unless tag_graph + + { + tag: group_tag_repr(tag_graph[:selected_tag]), + include_descendants: filters[:include_descendants] + } + end + + def group_tag_repr group_tag + { id: group_tag[:id], + name: group_tag[:name], + category: group_tag[:category] } + end + def material_index_order_sql filters direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC' sort_sql = diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 13c6562..fa2943e 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -2,6 +2,7 @@ import { Fragment, useEffect, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' 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' @@ -35,8 +36,23 @@ const setChildrenById = ( const materialPath = ( - tagName: string, - materialFilter: MaterialFilter): string => `/materials?q=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }` + tagId: number, + 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 => ({ @@ -55,6 +71,13 @@ const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ matchedAlias: null }) +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' + + const updateMaterialFilterQuery = ( pathname: string, locationSearch: string, @@ -72,16 +95,16 @@ const MaterialFilterButtons: FC<{ }> = ({ materialFilter, onChange }) => (
{FILTERS.map (value => ( - ))}
) @@ -89,12 +112,14 @@ const MaterialFilterButtons: FC<{ 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, setOpenTags, tag }) => { +}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId, + setOpenTags, tag }) => { const open = Boolean (openTags[tag.id]) const { data } = useQuery ({ queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), @@ -120,24 +145,27 @@ const MaterialTreeNode: FC<{ )}
- +
+ +
{open && tag.children.length > 0 && (
    {tag.children.map (child => ( - ))} @@ -149,11 +177,13 @@ const MaterialTreeNode: FC<{ 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, setOpenTags, tag }) => { +}> = ({ depth = 0, materialFilter, onChildren, openTags, selectedTagId, + setOpenTags, tag }) => { const open = Boolean (openTags[tag.id]) const { data } = useQuery ({ queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), @@ -167,18 +197,17 @@ const MobileMaterialTreeNode: FC<{ return (
    -
    +
    + to={materialPath (tag.id, materialFilter)}/>
    {tag.hasChildren && (
    )}
    ) @@ -219,6 +249,7 @@ const MaterialSidebar: FC = () => { const navigate = useNavigate () const qs = new URLSearchParams (location.search) const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'present') + const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null const [desktopTags, setDesktopTags] = useState ([]) const [openTags, setOpenTags] = useState> ({ }) @@ -257,12 +288,15 @@ const MaterialSidebar: FC = () => { updateMaterialFilterQuery (location.pathname, location.search, navigate, value) } + const clearTagSelection = clearTagSelectionPath (location.search, materialFilter) + const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => ( tags.map (tag => ( ))) @@ -271,6 +305,14 @@ const MaterialSidebar: FC = () => { <>
    +
    + + {selectedTagId != null ? '選択解除' : '全素材'} + +
    @@ -281,6 +323,7 @@ const MaterialSidebar: FC = () => { key={tag.id} tag={tag} materialFilter={materialFilter} + selectedTagId={selectedTagId} openTags={openTags} setOpenTags={setOpenTags} onChildren={setChildren}/>))} @@ -291,6 +334,12 @@ const MaterialSidebar: FC = () => {
    + + {selectedTagId != null ? '選択解除' : '全素材'} + diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts index 3930180..2acfd84 100644 --- a/frontend/src/lib/materials.ts +++ b/frontend/src/lib/materials.ts @@ -7,6 +7,7 @@ import { import type { Material, + MaterialIndexResponse, MaterialVersion, FetchMaterialsParams, MaterialFilter, @@ -20,11 +21,6 @@ export type FetchMaterialTreeParams = { materialFilter: MaterialFilter } -export type MaterialIndexResponse = { - materials: Material[] - count: number -} - export type MaterialSyncSuppressionResponse = { suppressions: MaterialSyncSuppression[] } @@ -48,11 +44,15 @@ export const parseMaterialFilter = ( export const fetchMaterials = async ( { q, tagState, mediaKind, createdFrom, createdTo, updatedFrom, updatedTo, sort, direction, page, + tagId, includeDescendants, groupBy, limit }: FetchMaterialsParams): Promise => await apiGet ('/materials', { params: { ...(q && { q }), tag_state: tagState, media_kind: mediaKind, + ...(tagId != null && { tag_id: tagId }), + ...(includeDescendants && { include_descendants: '1' }), + ...(groupBy !== 'none' && { group_by: groupBy }), ...(createdFrom && { created_from: createdFrom }), ...(createdTo && { created_to: createdTo }), ...(updatedFrom && { updated_from: updatedFrom }), diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx index e9c0aa2..333a869 100644 --- a/frontend/src/pages/materials/MaterialListPage.tsx +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -20,6 +20,9 @@ import type { FC, FormEvent } from 'react' import type { FetchMaterialsParams, Material, + MaterialFilter, + MaterialIndexGroup, + MaterialIndexGroupBy, MaterialIndexDirection, MaterialIndexMediaKind, MaterialIndexSort, @@ -50,6 +53,10 @@ const SORT_LABELS: Record = { version_no: 'バージョン', id: 'ID'} +const GROUP_BY_LABELS: Record = { + none: 'オフ', + parent_tag: '親タグ'} + const setIf = (qs: URLSearchParams, key: string, value: string | null) => { const next = value?.trim () @@ -79,6 +86,33 @@ const materialTitle = (material: Material): string => material.tag?.name ?? `素材 #${ material.id }` +const groupedTagPath = ( + tagId: number, + materialFilter: MaterialFilter): string => + `/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag` + + `&material_filter=${ materialFilter }` + + +const materialNewPath = ( + tagName: string, + returnTo: string): string => + `/materials/new?tag=${ encodeURIComponent (tagName) }` + + `&return_to=${ encodeURIComponent (returnTo) }` + + +const clearedTagSelectionPath = ( + locationSearch: string, + materialFilter: MaterialFilter): string => { + const qs = new URLSearchParams (locationSearch) + qs.delete ('tag_id') + qs.delete ('include_descendants') + qs.set ('group_by', 'none') + qs.delete ('page') + qs.set ('material_filter', materialFilter) + return `/materials?${ qs.toString () }` +} + + const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
    = ({ material }) => ( ) +const GroupHeading: FC<{ + count: number + materialFilter: MaterialFilter + returnTo: string + title: string + tagId: number +}> = ({ count, materialFilter, returnTo, title, tagId }) => ( +
    + + {title} + + + {count} 件 + + + このタグに素材を追加 + +
    ) + + const MaterialListPage: FC = () => { const location = useLocation () const navigate = useNavigate () @@ -162,7 +223,10 @@ const MaterialListPage: FC = () => { const page = Number (query.get ('page') ?? 1) const limit = Number (query.get ('limit') ?? 20) - const qQuery = query.get ('q') ?? query.get ('tag') ?? '' + const qQuery = query.get ('q') ?? '' + const tagIdQuery = Number (query.get ('tag_id') ?? 0) + const tagId = tagIdQuery > 0 ? tagIdQuery : null + const includeDescendants = query.get ('include_descendants') === '1' const tagState = query.get ('unclassified') === '1' ? 'untagged' : parseOption ( @@ -182,6 +246,11 @@ const MaterialListPage: FC = () => { query.get ('direction'), ['asc', 'desc'], 'desc') + const groupByQuery = parseOption ( + query.get ('group_by'), + ['none', 'parent_tag'], + 'none') + const groupBy = tagId == null ? 'none' : groupByQuery const view = parseOption (query.get ('view'), ['card', 'list'], 'card') const createdFromQuery = query.get ('created_from') ?? '' const createdToQuery = query.get ('created_to') ?? '' @@ -191,6 +260,7 @@ const MaterialListPage: FC = () => { const [q, setQ] = useState ('') const [tagStateInput, setTagStateInput] = useState ('all') const [mediaKindInput, setMediaKindInput] = useState ('all') + const [groupByInput, setGroupByInput] = useState ('none') const [createdFrom, setCreatedFrom] = useState (null) const [createdTo, setCreatedTo] = useState (null) const [updatedFrom, setUpdatedFrom] = useState (null) @@ -200,6 +270,9 @@ const MaterialListPage: FC = () => { q: qQuery, tagState, mediaKind, + tagId, + includeDescendants, + groupBy, createdFrom: createdFromQuery, createdTo: createdToQuery, updatedFrom: updatedFromQuery, @@ -212,18 +285,33 @@ const MaterialListPage: FC = () => { const { data, isLoading, isError } = useQuery ({ queryKey: materialsKeys.index (keys), queryFn: () => fetchMaterials (keys)}) + const tagScope = data?.tagScope ?? null const materials = data?.materials ?? [] + const groups = data?.groups ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 + const materialsById = useMemo ( + () => new Map (materials.map (material => [material.id, material])), + [materials], + ) + const groupedMaterialIds = useMemo ( + () => new Set (groups.flatMap (group => group.materialIds)), + [groups], + ) + const ungroupedMaterials = useMemo ( + () => materials.filter (material => !groupedMaterialIds.has (material.id)), + [groupedMaterialIds, materials], + ) useEffect (() => { setQ (qQuery) setTagStateInput (tagState) setMediaKindInput (mediaKind) + setGroupByInput (groupBy) setCreatedFrom (createdFromQuery) setCreatedTo (createdToQuery) setUpdatedFrom (updatedFromQuery) setUpdatedTo (updatedToQuery) - }, [createdFromQuery, createdToQuery, mediaKind, qQuery, tagState, + }, [createdFromQuery, createdToQuery, groupBy, mediaKind, qQuery, tagState, updatedFromQuery, updatedToQuery]) const search = (e: FormEvent) => { @@ -239,19 +327,78 @@ const MaterialListPage: FC = () => { setIf (qs, 'updated_to', updatedTo) qs.set ('sort', sort) qs.set ('direction', direction) + qs.set ('group_by', tagId == null ? 'none' : groupByInput) qs.set ('view', view) qs.set ('page', '1') qs.set ('limit', String (limit)) qs.set ('material_filter', materialFilter) + if (tagId != null) + qs.set ('tag_id', String (tagId)) + + if (includeDescendants) + qs.set ('include_descendants', '1') + navigate (`/materials?${ qs.toString () }`) } const updateQuery = (changes: Record) => { const qs = new URLSearchParams (location.search) - Object.entries (changes).forEach (([key, value]) => qs.set (key, value)) + Object.entries (changes).forEach (([key, value]) => { + if (value === '') + qs.delete (key) + else + qs.set (key, value) + }) navigate (`/materials?${ qs.toString () }`) } + const renderMaterialCollection = (rows: Material[]) => + view === 'card' + ? ( +
    + {rows.map (material => ( + ))} +
    ) + : ( +
    + {rows.map (material => ( + ))} +
    ) + + const renderGroupedMaterials = (materialGroups: MaterialIndexGroup[]) => ( +
    + {materialGroups.map (group => { + const groupMaterials = + group.materialIds + .map (materialId => materialsById.get (materialId)) + .filter ((material): material is Material => material != null) + + if (groupMaterials.length === 0) + return null + + return ( +
    + + {renderMaterialCollection (groupMaterials)} +
    ) + })} + {ungroupedMaterials.length > 0 && ( +
    +
    + + その他 + +
    + {renderMaterialCollection (ungroupedMaterials)} +
    )} +
    ) + return ( @@ -303,6 +450,28 @@ const MaterialListPage: FC = () => {
    + {tagScope && ( +
    + + {tagScope.tag.name} + {tagScope.includeDescendants ? ' 配下の素材を表示中' : ' の素材を表示中'} + + + このタグに素材を追加 + + + タグ選択を解除 + +
    )} + {tagState === 'untagged' && ( { )} + + {({ invalid }) => ( + )} + + {() => (
    @@ -438,18 +622,9 @@ const MaterialListPage: FC = () => { {(!isLoading && !isError && materials.length === 0) && (

    素材はありません.

    )} {materials.length > 0 && ( - view === 'card' - ? ( -
    - {materials.map (material => ( - ))} -
    ) - : ( -
    - {materials.map (material => ( - ))} -
    ))} + groupBy === 'parent_tag' && groups.length > 0 + ? renderGroupedMaterials (groups) + : renderMaterialCollection (materials))}
    ) diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx index 18421d3..d320ba7 100644 --- a/frontend/src/pages/materials/MaterialNewPage.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.tsx @@ -27,6 +27,7 @@ const MaterialNewPage: FC = () => { const location = useLocation () const query = new URLSearchParams (location.search) const tagQuery = query.get ('tag') ?? '' + const returnToQuery = query.get ('return_to') ?? '' const navigate = useNavigate () @@ -54,7 +55,7 @@ const MaterialNewPage: FC = () => { onSuccess: async () => { await qc.invalidateQueries ({ queryKey: materialsKeys.root }) toast ({ title: '送信成功!' }) - navigate (`/materials?tag=${ encodeURIComponent (tag) }`) + navigate (returnToQuery || `/materials?tag=${ encodeURIComponent (tag) }`) }, onError: error => { applyValidationError (error) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 553fafd..53ea0b8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -88,12 +88,26 @@ export type MaterialIndexSort = export type MaterialIndexTagState = 'all' | 'tagged' | 'untagged' +export type MaterialIndexGroupBy = 'none' | 'parent_tag' + export type MaterialIndexView = 'card' | 'list' +export type MaterialTagScope = { + tag: { + id: number + name: string + category: Category + } + includeDescendants: boolean +} + export type FetchMaterialsParams = { q: string tagState: MaterialIndexTagState mediaKind: MaterialIndexMediaKind + tagId: number | null + includeDescendants: boolean + groupBy: MaterialIndexGroupBy createdFrom: string createdTo: string updatedFrom: string @@ -170,6 +184,23 @@ export type MaterialExportItem = { exportPath: string enabled: boolean } +export type MaterialIndexGroup = { + key: string + tag: { + id: number + name: string + category: Category + } + materialIds: number[] + count: number } + +export type MaterialIndexResponse = { + materials: Material[] + count: number + groups?: MaterialIndexGroup[] + tagScope?: MaterialTagScope | null +} + export type MaterialVersion = { id: number materialId: number