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 && (