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 && (
{tag.children.map (child => (
-
+
))}
)}
)
@@ -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