diff --git a/AGENTS.md b/AGENTS.md index 39dfaeb..6dfdee8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,10 @@ npm run preview - Tabs are only for leading indentation, never for spaces after non-space text. - TypeScript and TSX imports may stay on one line if they remain within the line limit; do not expand short type-only imports mechanically. +- In TypeScript and TSX, when breaking a line at an operator, break before the + operator and put the operator at the beginning of the next line. A trailing + operator at end of line is unacceptable. This rule does not apply to Ruby, + where it can change the syntactic structure. - In TypeScript and TSX, when a function takes one destructured object argument plus an inline type, prefer this shape when it fits locally: @@ -231,6 +235,22 @@ const value = - Keep page-level code under `frontend/src/pages` and shared UI/feature code under `frontend/src/components` unless existing patterns point elsewhere. - Match existing Tailwind, component, and import alias conventions. +- `` is acceptable for event-only controls when it fits the local + UI pattern. Do not use `` for internal navigation or other non-external + links. +- Internal links must use `PrefetchLink`. +- External links must use `` with `target="_blank"`. +- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an + explicit readable `text-*` color and dark-mode counterparts such as + `dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present. +- Do not rely on inherited text color for light backgrounds. This is especially + important for chips, cards, buttons, and panels that may inherit white text in + dark mode. +- Mobile UI must be checked as a first-class layout. Avoid wide fixed content, + make dense controls wrap or scroll intentionally, and keep tag/filter + controls usable without horizontal page overflow. +- For mobile horizontal scrollers, make the scroll direction and item sizing + explicit, and ensure chip text remains readable in both light and dark modes. - In TypeScript and TSX, prefer direct comparison operators such as `===` and `!==` over negating a comparison like `!(a === b)`. - In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index 77d6402..bf383ac 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -14,10 +14,15 @@ class MaterialsController < ApplicationController tag_id = params[:tag_id].presence parent_id = params[:parent_id].presence + unclassified = bool?(:unclassified) q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file - q = q.where(tag_id:) if tag_id - q = q.where(parent_id:) if parent_id + if unclassified + q = q.where(tag_id: nil) + else + q = q.where(tag_id:) if tag_id + q = q.where(parent_id:) if parent_id + end count = q.count materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index f847530..43f22f2 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -82,8 +82,9 @@ class TagsController < ApplicationController def with_depth parent_tag_id = params[:parent].to_i parent_tag_id = nil if parent_tag_id <= 0 + material_filter = material_filter_param(default: 'any') - graph = build_with_depth_graph + graph = build_with_depth_graph(material_filter) tag_ids = if parent_tag_id @@ -92,19 +93,9 @@ class TagsController < ApplicationController visible_root_tag_ids(graph) end - tags = - Tag - .joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .where(id: tag_ids) - .order('tag_names.name') - .distinct - .to_a - - render json: tags.map { |tag| - TagRepr.base(tag).merge(has_children: visible_child_tag_ids(tag.id, graph).present?, - children: []) - } + render json: tag_ids + .sort_by { |tag_id| graph[:tags_by_id][tag_id][:name] } + .map { |tag_id| with_depth_lightweight_repr(tag_id, graph) } end def autocomplete @@ -229,13 +220,16 @@ class TagsController < ApplicationController def materials_by_name name = params[:name].to_s.strip return render_bad_request('name は必須です.') if name.blank? + material_filter = material_filter_param(default: 'any') tag = Tag.joins(:tag_name) .includes(:tag_name, :materials, tag_name: :wiki_page) .find_by(tag_names: { name: }) return head :not_found unless tag - render json: build_tag_children(tag) + graph = build_with_depth_graph(material_filter) + + render json: build_tag_children(tag, graph:) end def update_all @@ -348,7 +342,14 @@ class TagsController < ApplicationController private - def build_with_depth_graph + def material_filter_param default: + value = params[:material_filter].to_s.presence + return default unless ['present', 'missing', 'any'].include?(value) + + value + end + + def build_with_depth_graph material_filter children_by_parent_id = Hash.new { |h, k| h[k] = [] } parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] } @@ -361,20 +362,26 @@ class TagsController < ApplicationController parent_ids_by_child_id.keys + Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq - tags_by_id = Tag.where(id: tag_ids) - .pluck(:id, :category, :deprecated_at) - .each_with_object({ }) do |(id, category, deprecated_at), h| - h[id] = { category:, deprecated: deprecated_at.present? } + material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set + + tags_by_id = Tag.joins(:tag_name) + .where(id: tag_ids) + .pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at') + .each_with_object({ }) do |(id, name, category, deprecated_at), h| + h[id] = { name:, category:, deprecated: deprecated_at.present?, + has_material: material_tag_ids.include?(id) } end { children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:, - visible_child_tag_ids_by_parent_id: { } } + visible_child_tag_ids_by_parent_id: { }, + visible_subtree_by_tag_id: { }, + material_filter: } end def visible_root_tag_ids graph - graph[:tags_by_id].filter_map do |tag_id, attrs| - next unless with_depth_visible_tag?(attrs) + graph[:tags_by_id].filter_map do |tag_id, _attrs| next unless visible_root_tag?(tag_id, graph) + next unless visible_subtree?(tag_id, graph) tag_id end @@ -428,18 +435,76 @@ class TagsController < ApplicationController return end - visible_ids << tag_id if with_depth_visible_tag?(tag) + if with_depth_visible_tag?(tag, graph[:material_filter]) + visible_ids << tag_id + return + end + + visible_ids << tag_id if graph[:children_by_parent_id][tag_id].any? { |child_tag_id| + visible_subtree?(child_tag_id, graph) + } end - def with_depth_visible_tag? tag - tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated] + def with_depth_visible_tag? tag, material_filter + return false unless tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated] + + case material_filter + when 'present' + tag[:has_material] + when 'missing' + !tag[:has_material] + else + true + end end - def build_tag_children tag + def visible_subtree? tag_id, graph + cache = graph[:visible_subtree_by_tag_id] + return cache[tag_id] if cache.key?(tag_id) + + tag = graph[:tags_by_id][tag_id] + return cache[tag_id] = false unless tag + + if tag[:deprecated] + return cache[tag_id] = + graph[:children_by_parent_id][tag_id].any? { |child_tag_id| + visible_subtree?(child_tag_id, graph) + } + end + + cache[tag_id] = + with_depth_visible_tag?(tag, graph[:material_filter]) || + graph[:children_by_parent_id][tag_id].any? { |child_tag_id| + visible_subtree?(child_tag_id, graph) + } + end + + def with_depth_lightweight_repr tag_id, graph + tag = graph[:tags_by_id][tag_id] + + { id: tag_id, + name: tag[:name], + category: tag[:category], + deprecated: tag[:deprecated], + has_material: tag[:has_material], + has_children: visible_child_tag_ids(tag_id, graph).present?, + children: [] } + end + + def build_tag_children tag, graph: nil material = tag.materials.first + tag_graph = graph && graph[:tags_by_id][tag.id] + material = + nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter]) + + children = tag.children.sort_by(&:name) + if graph + children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) } + end TagRepr.base(tag).merge( - children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, + children: children.map { build_tag_children(_1, graph:) }, + has_material: tag_graph ? tag_graph[:has_material] : material.present?, material: material && MaterialRepr.base(material, host: request.base_url)) end diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 875a967..4122acb 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -1,23 +1,31 @@ -import { Fragment, useEffect, useState } from 'react' +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 { apiGet } from '@/lib/api' +import { materialsKeys } from '@/lib/queryKeys' +import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials' -import type { FC, ReactNode } from 'react' +import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' -import type { Tag } from '@/types' +import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types' -type TagWithDepth = Tag & { - hasChildren: boolean - children: TagWithDepth[] } +const FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] + +const FILTER_LABELS: Record = { + present: '素材あり', + missing: '素材なし', + any: 'すべて', +} const setChildrenById = ( - tags: TagWithDepth[], + tags: MaterialSidebarTag[], targetId: number, - children: TagWithDepth[], -): TagWithDepth[] => ( + children: MaterialSidebarTag[], +): MaterialSidebarTag[] => ( tags.map (tag => { if (tag.id === targetId) return { ...tag, children } @@ -25,75 +33,305 @@ const setChildrenById = ( if (tag.children.length === 0) return tag - return { ...tag, - children: (setChildrenById (tag.children, targetId, children) - .filter (t => t.category !== 'meme' || t.hasChildren)) } + return { ...tag, children: setChildrenById (tag.children, targetId, children) } })) -const MaterialSidebar: FC = () => { - const [tags, setTags] = useState ([]) - const [openTags, setOpenTags] = useState> ({ }) - const [tagFetchedFlags, setTagFetchedFlags] = useState> ({ }) +const materialPath = ( + tagName: string, + materialFilter: MaterialFilter, +): string => `/materials?tag=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }` - useEffect (() => { - void (async () => { - setTags ((await apiGet ('/tags/with-depth')) - .filter (t => t.category !== 'meme' || t.hasChildren)) - }) () - }, []) - const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => ( - ts.map (t => ( - -
  • -
    -
    - {t.hasChildren && ( - { - e.preventDefault () - if (!(tagFetchedFlags[t.id])) - { - try - { - const data = - await apiGet ( - '/tags/with-depth', { params: { parent: String (t.id) } }) - setTags (prev => setChildrenById (prev, t.id, data)) - setTagFetchedFlags (prev => ({ ...prev, [t.id]: true })) - } - catch - { - ; - } - } - setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) })) - }}> - {openTags[t.id] ? <>− : '+'} - )} -
    -
    - -
    -
    -
  • - {openTags[t.id] && renderTags (t.children, nestLevel + 1)} - ))) +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 }) - return ( - - - ) + +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 () }` : '' }`) } -export default MaterialSidebar \ No newline at end of file + +const MaterialFilterButtons: FC<{ + materialFilter: MaterialFilter + onChange: (materialFilter: MaterialFilter) => void +}> = ({ materialFilter, onChange }) => ( +
    + {FILTERS.map (value => ( + ))} +
    ) + + +const MaterialTreeNode: FC<{ + materialFilter: MaterialFilter + nestLevel?: number + onChildren: (tagId: number, children: MaterialSidebarTag[]) => void + openTags: Record + setOpenTags: Dispatch>> + tag: MaterialSidebarTag +}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, 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 + materialFilter: MaterialFilter + onChildren: (tagId: number, children: MaterialSidebarTag[]) => void + openTags: Record + setOpenTags: Dispatch>> + tag: MaterialSidebarTag +}> = ({ depth = 0, materialFilter, onChildren, openTags, 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 && ( +
    +
    )} +
    ) +} + + +const MaterialSidebar: FC = () => { + const location = useLocation () + const navigate = useNavigate () + const qs = new URLSearchParams (location.search) + const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'present') + + const [desktopTags, setDesktopTags] = useState ([]) + const [openTags, setOpenTags] = useState> ({ }) + const mobileRailRef = useRef (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]) + + 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 diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts new file mode 100644 index 0000000..900c9c4 --- /dev/null +++ b/frontend/src/lib/materials.ts @@ -0,0 +1,114 @@ +import { + apiGet, + isApiError, + apiPatch, + apiPost, + apiPut, +} from '@/lib/api' + +import type { + Material, + MaterialFilter, + MaterialSidebarTag, + MaterialTagTree, +} from '@/types' + +export type FetchMaterialsParams = { + page?: number + limit?: number + tagId?: number | null + parentId?: number | null + unclassified?: boolean +} + +export type FetchMaterialTreeParams = { + parentId?: number | null + materialFilter: MaterialFilter +} + +export type MaterialIndexResponse = { + materials: Material[] + count: number +} + +const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] + + +export const parseMaterialFilter = ( + value: unknown, + fallback: MaterialFilter = 'present', +): MaterialFilter => + typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter) + ? value as MaterialFilter + : fallback + + +export const fetchMaterials = async ( + { page, limit, tagId, parentId, unclassified }: FetchMaterialsParams, +): Promise => + await apiGet ('/materials', { params: { + ...(page != null && { page }), + ...(limit != null && { limit }), + ...(tagId != null && { tag_id: tagId }), + ...(parentId != null && { parent_id: parentId }), + ...(unclassified && { unclassified: '1' }), + } }) + + +export const fetchMaterial = async (id: string): Promise => { + try + { + return await apiGet (`/materials/${ id }`) + } + catch (error) + { + if (isApiError (error) && error.response?.status === 404) + return null + throw error + } +} + + +export const fetchMaterialTagTree = async ( + { parentId, materialFilter }: FetchMaterialTreeParams, +): Promise => + await apiGet ('/tags/with-depth', { params: { + ...(parentId != null && { parent: String (parentId) }), + material_filter: materialFilter, + } }) + + +export const fetchMaterialTagByName = async ( + name: string, + materialFilter: MaterialFilter, +): Promise => { + try + { + return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`, + { params: { material_filter: materialFilter } }) + } + catch (error) + { + if (isApiError (error) && error.response?.status === 404) + return null + throw error + } +} + + +export const createMaterial = async (formData: FormData): Promise => + await apiPost ('/materials', formData) + + +export const updateMaterial = async ( + id: string, + formData: FormData, +): Promise => + await apiPut (`/materials/${ id }`, formData) + + +export const suppressMaterialFile = async ( + id: string, + payload: { reason: string; purge?: boolean }, +): Promise => + await apiPatch (`/materials/${ id }/suppress_file`, payload) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 7e10ab1..3e5f017 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -1,4 +1,9 @@ -import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types' +import type { + FetchNicoTagsParams, + FetchPostsParams, + FetchTagsParams, + MaterialFilter, +} from '@/types' export const postsKeys = { root: ['posts'] as const, @@ -25,6 +30,26 @@ export const tagsKeys = { ['tags', 'changes', p] as const, deerjikists: (id: string) => ['tags', 'deerjikists', id] as const } +export const materialsKeys = { + root: ['materials'] as const, + index: (p: { + page?: number + limit?: number + tagId?: number | null + parentId?: number | null + unclassified?: boolean + }) => ['materials', 'index', p] as const, + byTagName: (name: string, materialFilter: MaterialFilter) => + ['materials', 'tag', name, materialFilter] as const, + show: (id: string) => ['materials', id] as const, + tree: (p: { + parentId?: number | null + materialFilter: MaterialFilter + }) => ['materials', 'tree', p] as const, + unclassified: (p: { page?: number; limit?: number } = { }) => + ['materials', 'unclassified', p] as const, +} + export const wikiKeys = { root: ['wiki'] as const, index: (p: { title?: string }) => ['wiki', 'index', p] as const, diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index 782505b..a8acf66 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -1,3 +1,4 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' @@ -13,248 +14,254 @@ import MainArea from '@/components/layout/MainArea' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' -import { apiGet, apiPatch, apiPut } from '@/lib/api' +import { + suppressMaterialFile, + fetchMaterial, + updateMaterial, +} from '@/lib/materials' +import { materialsKeys } from '@/lib/queryKeys' import { inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC } from 'react' -import type { Material, Tag, User } from '@/types' - -type MaterialWithTag = Material & { tag: Tag } +import type { User } from '@/types' type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths' const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { const { id } = useParams () + const qc = useQueryClient () const [exportPath, setExportPath] = useState ('') const [file, setFile] = useState (null) const [filePreview, setFilePreview] = useState ('') - const [loading, setLoading] = useState (false) - const [material, setMaterial] = useState (null) - const [sending, setSending] = useState (false) const [tag, setTag] = useState ('') const [url, setURL] = useState ('') const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () - const handleSubmit = async () => { - clearValidationErrors () + const { data: material, isError, isLoading } = useQuery ({ + queryKey: materialsKeys.show (id ?? ''), + queryFn: () => fetchMaterial (id ?? ''), + enabled: id != null, + }) - const formData = new FormData - if (tag.trim ()) - formData.append ('tag', tag) - if (file) - formData.append ('file', file) - if (url.trim ()) - formData.append ('url', url) - formData.append ('export_paths[legacy_drive]', exportPath) + useEffect (() => { + if (!(material)) + return - try - { - setSending (true) - const data = await apiPut (`/materials/${ id }`, formData) - setMaterial (data) - toast ({ title: '更新成功!' }) - } - catch (e) - { - applyValidationError (e) - toast ({ title: '更新失敗……', description: '入力を見直してください.' }) - } - finally - { - setSending (false) - } + setTag (material.tag.name) + setURL (material.url ?? '') + setExportPath (material.exportPaths.legacyDrive ?? '') + if (material.file && material.contentType) + { + setFilePreview (material.file) + setFile (null) + } + }, [material]) + + const invalidateMaterialQueries = async () => { + await qc.invalidateQueries ({ queryKey: materialsKeys.root }) } - const handleSuppress = async () => { + const updateMutation = useMutation ({ + mutationFn: async () => { + const formData = new FormData + if (tag.trim ()) + formData.append ('tag', tag) + if (file) + formData.append ('file', file) + if (url.trim ()) + formData.append ('url', url) + formData.append ('export_paths[legacy_drive]', exportPath) + + return await updateMaterial (id ?? '', formData) + }, + onSuccess: async data => { + qc.setQueryData (materialsKeys.show (id ?? ''), data) + await invalidateMaterialQueries () + toast ({ title: '更新成功!' }) + }, + onError: error => { + applyValidationError (error) + toast ({ title: '更新失敗……', description: '入力を見直してください.' }) + }, + }) + + const suppressMutation = useMutation ({ + mutationFn: async (reason: string) => + await suppressMaterialFile (id ?? '', { reason }), + onSuccess: async data => { + qc.setQueryData (materialsKeys.show (id ?? ''), data) + setFile (null) + setFilePreview ('') + await invalidateMaterialQueries () + toast ({ title: '抑止しました' }) + }, + onError: () => { + toast ({ title: '抑止に失敗しました' }) + }, + }) + + const handleSubmit = () => { + clearValidationErrors () + updateMutation.mutate () + } + + const handleSuppress = () => { const reason = window.prompt ('抑止理由を入力してください。') if (reason == null || reason.trim () === '') return if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。')) return - try - { - const data = await apiPatch ( - `/materials/${ id }/suppress_file`, - { reason }, - ) - setMaterial (data) - setFile (null) - setFilePreview ('') - toast ({ title: '抑止しました' }) - } - catch - { - toast ({ title: '抑止に失敗しました' }) - } + suppressMutation.mutate (reason) } - useEffect (() => { - if (!(id)) - return - - void (async () => { - try - { - setLoading (true) - const data = await apiGet (`/materials/${ id }`) - setMaterial (data) - setTag (data.tag.name) - if (data.file && data.contentType) - { - setFilePreview (data.file) - setFile (null) - } - setURL (data.url ?? '') - setExportPath (data.exportPaths.legacyDrive ?? '') - } - finally - { - setLoading (false) - } - }) () - }, [id]) - return ( - {material && ( - - {`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`} - )} + {material && ( + + {`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`} + )} - {loading ? 'Loading...' : (material && ( - <> - - - + {isLoading ? 'Loading...' : isError ? ( +

    + 素材の取得に失敗しました. +

    + ) : material == null ? ( +

    + 素材が見つかりませんでした. +

    + ) : ( + <> + + + - {material.fileSuppressedAt && ( -
    - 素材ファイルは抑止済みです。 - {material.fileSuppressionReason && ( - 理由: {material.fileSuppressionReason})} -
    )} + {material.fileSuppressedAt && ( +
    + 素材ファイルは抑止済みです。 + {material.fileSuppressionReason && ( + 理由: {material.fileSuppressionReason})} +
    )} - {(!material.fileSuppressedAt && material.file && material.contentType) && ( - (/image\/.*/.test (material.contentType) && ( - {material.tag.name)) - || (/video\/.*/.test (material.contentType) && ( -
    ) } diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx index 413427a..4e1cc57 100644 --- a/frontend/src/pages/materials/MaterialListPage.tsx +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -1,176 +1,328 @@ -import { Fragment, useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Fragment, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import nikumaru from '@/assets/fonts/nikumaru.otf' import PrefetchLink from '@/components/PrefetchLink' import TagLink from '@/components/TagLink' +import FormField from '@/components/common/FormField' import PageTitle from '@/components/common/PageTitle' import SectionTitle from '@/components/common/SectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle' +import TagInput from '@/components/common/TagInput' import MainArea from '@/components/layout/MainArea' import { API_BASE_URL, SITE_TITLE } from '@/config' -import { apiGet } from '@/lib/api' +import { + fetchMaterials, + fetchMaterialTagByName, + parseMaterialFilter, +} from '@/lib/materials' +import { materialsKeys } from '@/lib/queryKeys' +import { inputClass } from '@/lib/utils' import type { FC } from 'react' -import type { Material, Tag } from '@/types' +import type { Material, MaterialFilter, MaterialTagTree } from '@/types' -type TagWithMaterial = Omit & { - children: TagWithMaterial[] - material: Material | null } +const MATERIAL_FILTER_LABELS: Record = { + present: '素材あり', + missing: '素材なし', + any: 'すべて', +} -const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => { +const MaterialCard = ({ tag }: { tag: MaterialTagTree }) => { if (!(tag.material)) - return + return null return ( -
    - {tag.material.fileSuppressedAt - ? 抑止済み - : (tag.material.contentType && /image\/.*/.test (tag.material.contentType)) - ? - : 照会} -
    + to={`/materials/${ tag.material.id }`} + className="block h-40 w-40"> +
    + {tag.material.fileSuppressedAt + ? 抑止済み + : (tag.material.contentType && /image\/.*/.test (tag.material.contentType)) + ? + : 照会} +
    ) } -const MaterialListPage: FC = () => { - const [loading, setLoading] = useState (false) - const [tag, setTag] = useState (null) +const MaterialList = ({ materials }: { materials: Material[] }) => ( +
    + {materials.map (material => ( +
    +
    +
    +

    + {material.tag?.name ?? '未分類素材'} +

    + {material.fileSuppressedAt && ( +

    抑止済み

    )} +
    + + 照会 + +
    + {material.exportPaths.legacyDrive && ( +

    + {material.exportPaths.legacyDrive} +

    )} +
    ))} +
    ) + +const MaterialTagTreeView = ({ tag }: { tag: MaterialTagTree }) => ( + <> + + + + {(!tag.material && tag.hasMaterial !== true && tag.category !== 'meme') && ( +
    + + 追加 + +
    )} + + + +
    + {tag.children.map (c2 => ( + + + + + {(!c2.material && c2.hasMaterial !== true && c2.category !== 'meme') && ( +
    + + 追加 + +
    )} + + + +
    + {c2.children.map (c3 => ( + + + + + {(!c3.material && c3.hasMaterial !== true && c3.category !== 'meme') && ( +
    + + 追加 + +
    )} + + +
    ))} +
    +
    ))} +
    + ) + + +const MaterialSearchTop: FC<{ + materialFilter: MaterialFilter + setMaterialFilter: (value: MaterialFilter) => void + tagName: string + setTagName: (value: string) => void +}> = ({ materialFilter, setMaterialFilter, tagName, setTagName }) => { + const navigate = useNavigate () const location = useLocation () - const query = new URLSearchParams (location.search) + + const handleSearch = () => { + const qs = new URLSearchParams (location.search) + if (tagName.trim ()) + qs.set ('tag', tagName.trim ()) + else + qs.delete ('tag') + qs.delete ('unclassified') + qs.set ('material_filter', materialFilter) + navigate (`/materials?${ qs.toString () }`) + } + + return ( +
    + 素材集 + +
    +
    + + {() => ( + )} + + + + {({ invalid }) => ( + )} + + +
    + + + 未分類素材を見る + + + 新規素材を追加 + + + ZIP をダウンロード + +
    +
    +
    +
    ) +} + + +const MaterialListPage: FC = () => { + const location = useLocation () + const query = useMemo (() => new URLSearchParams (location.search), [location.search]) const tagQuery = query.get ('tag') ?? '' + const unclassified = query.get ('unclassified') === '1' + const initialFilter = parseMaterialFilter (query.get ('material_filter'), 'present') + + const [tagName, setTagName] = useState (tagQuery) + const [materialFilter, setMaterialFilter] = useState (initialFilter) + + const { + data: tag, + isLoading: tagLoading, + isError: tagError, + } = useQuery ({ + queryKey: materialsKeys.byTagName (tagQuery, initialFilter), + queryFn: () => fetchMaterialTagByName (tagQuery, initialFilter), + enabled: tagQuery !== '' && !unclassified, + }) + + const { + data: unclassifiedData, + isLoading: unclassifiedLoading, + isError: unclassifiedError, + } = useQuery ({ + queryKey: materialsKeys.unclassified ({ page: 1, limit: 50 }), + queryFn: () => fetchMaterials ({ page: 1, limit: 50, unclassified: true }), + enabled: unclassified, + }) useEffect (() => { - if (!(tagQuery)) - { - setTag (null) - return - } - - void (async () => { - try - { - setLoading (true) - setTag ( - await apiGet ( - `/tags/name/${ encodeURIComponent (tagQuery) }/materials`)) - } - finally - { - setLoading (false) - } - }) () - }, [location.search, tagQuery]) + setTagName (tagQuery) + setMaterialFilter (initialFilter) + }, [initialFilter, tagQuery]) return ( - - - {`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`} - + + + {`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`} + - {loading ? 'Loading...' : ( - tag - ? ( - <> - - - - {(!(tag.material) && tag.category !== 'meme') && ( -
    - - 追加 - -
    )} - - - -
    - {tag.children.map (c2 => ( - - - - - {(!(c2.material) && c2.category !== 'meme') && ( -
    - - 追加 - -
    )} - - - -
    - {c2.children.map (c3 => ( - - - - - {(!(c3.material) && c3.category !== 'meme') && ( -
    - - 追加 - -
    )} - - -
    ))} -
    -
    ))} -
    - ) - : ( - <> -

    左のリストから照会したいタグを選択してください。

    -

    もしくは……

    - - ))} + {unclassified ? ( + <> + 未分類素材 +
    + + 素材検索トップへ戻る + +
    + {unclassifiedLoading &&

    Loading...

    } + {unclassifiedError && ( +

    未分類素材の取得に失敗しました.

    )} + {unclassifiedData && unclassifiedData.materials.length === 0 && ( +

    未分類素材はありません.

    )} + {unclassifiedData && unclassifiedData.materials.length > 0 && ( + )} + ) : tagQuery ? ( + <> + {tagLoading &&

    Loading...

    } + {tagError && ( +

    素材一覧の取得に失敗しました.

    )} + {(!tagLoading && !tagError && !tag) && ( +

    該当する素材タグが見つかりませんでした.

    )} + {tag && } + ) : ( + )}
    ) } diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx index 0571580..c65326b 100644 --- a/frontend/src/pages/materials/MaterialNewPage.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.tsx @@ -1,3 +1,4 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' @@ -11,7 +12,8 @@ import MainArea from '@/components/layout/MainArea' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' -import { apiPost } from '@/lib/api' +import { createMaterial } from '@/lib/materials' +import { materialsKeys } from '@/lib/queryKeys' import { inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' @@ -21,6 +23,7 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths' const MaterialNewPage: FC = () => { + const qc = useQueryClient () const location = useLocation () const query = new URLSearchParams (location.search) const tagQuery = query.get ('tag') ?? '' @@ -29,41 +32,39 @@ const MaterialNewPage: FC = () => { const [file, setFile] = useState (null) const [filePreview, setFilePreview] = useState ('') - const [sending, setSending] = useState (false) const [tag, setTag] = useState (tagQuery) const [url, setURL] = useState ('') const [exportPath, setExportPath] = useState ('') const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () - const handleSubmit = async () => { + const createMutation = useMutation ({ + mutationFn: async () => { + const formData = new FormData + if (tag) + formData.append ('tag', tag) + if (file) + formData.append ('file', file) + if (url) + formData.append ('url', url) + formData.append ('export_paths[legacy_drive]', exportPath) + + return await createMaterial (formData) + }, + onSuccess: async () => { + await qc.invalidateQueries ({ queryKey: materialsKeys.root }) + toast ({ title: '送信成功!' }) + navigate (`/materials?tag=${ encodeURIComponent (tag) }`) + }, + onError: error => { + applyValidationError (error) + toast ({ title: '送信失敗……', description: '入力を見直してください.' }) + }, + }) + + const handleSubmit = () => { clearValidationErrors () - - const formData = new FormData - if (tag) - formData.append ('tag', tag) - if (file) - formData.append ('file', file) - if (url) - formData.append ('url', url) - formData.append ('export_paths[legacy_drive]', exportPath) - - try - { - setSending (true) - await apiPost ('/materials', formData) - toast ({ title: '送信成功!' }) - navigate (`/materials?tag=${ encodeURIComponent (tag) }`) - } - catch (e) - { - applyValidationError (e) - toast ({ title: '送信失敗……', description: '入力を見直してください.' }) - } - finally - { - setSending (false) - } + createMutation.mutate () } return ( @@ -153,7 +154,7 @@ const MaterialNewPage: FC = () => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9ce4054..41d3839 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -65,6 +65,8 @@ export type FetchNicoTagsOrder = `${ FetchNicoTagsOrderField }:${ 'asc' | 'desc' export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at' +export type MaterialFilter = 'present' | 'missing' | 'any' + export type Material = { id: number versionNo: number @@ -82,6 +84,21 @@ export type Material = { updatedAt: string updatedByUser: { id: number; name: string } } +export type MaterialSidebarTag = { + id: number + name: string + category: Category + deprecated: boolean + hasChildren: boolean + hasMaterial: boolean + children: MaterialSidebarTag[] } + +export type MaterialTagTree = Omit & { + hasChildren: boolean + hasMaterial: boolean + children: MaterialTagTree[] + material?: Material | null } + export type MaterialExportItem = { id: number profile: string