diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 7e04e0e..c48e447 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -84,6 +84,7 @@ class TagsController < ApplicationController Tag .joins(:tag_name) .includes(:tag_name, tag_name: :wiki_page) + .where(category: [:meme, :character, :material]) .where(id: tag_ids) .order('tag_names.name') .distinct @@ -93,7 +94,12 @@ class TagsController < ApplicationController if tags.empty? [] else - TagImplication.where(parent_tag_id: tags.map(&:id)).distinct.pluck(:parent_tag_id) + TagImplication + .joins(:tag) + .where(parent_tag_id: tags.map(&:id), + tags: { category: [:meme, :character, :material] }) + .distinct + .pluck(:parent_tag_id) end render json: tags.map { |tag| @@ -101,6 +107,18 @@ class TagsController < ApplicationController } end + def materials_by_name + name = params[:name].to_s.strip + return head :bad_request if name.blank? + + tag = Tag.joins(:tag_name) + .includes(:tag_name, tag_name: :wiki_page) + .find_by(tag_names: { name: }) + return :not_found unless tag + + render json: build_tag_children(tag) + end + def autocomplete q = params[:q].to_s.strip.sub(/\Anot:/i, '') @@ -210,4 +228,10 @@ class TagsController < ApplicationController render json: TagRepr.base(tag) end + + private + + def build_tag_children tag + TagRepr.base(tag).merge(children: tag.children.map { build_tag_children(_1) }) + end end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index f724054..fc56aa4 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -13,6 +13,7 @@ Rails.application.routes.draw do scope :name do get ':name/deerjikists', action: :deerjikists_by_name + get ':name/materials', action: :materials_by_name get ':name', action: :show_by_name end end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 668de02..f306f47 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,8 +10,9 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' -import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' // import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' +import MaterialListPage from '@/pages/materials/MaterialListPage' +import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' import PostDetailPage from '@/pages/posts/PostDetailPage' @@ -53,6 +54,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> {/* }/> */} }/> diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 8494eb4..ee65d7c 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { Fragment, useEffect, useState } from 'react' import TagLink from '@/components/TagLink' import SidebarComponent from '@/components/layout/SidebarComponent' @@ -13,58 +13,84 @@ type TagWithDepth = Tag & { children: TagWithDepth[] } +const setChildrenById = ( + tags: TagWithDepth[], + targetId: number, + children: TagWithDepth[], +): TagWithDepth[] => ( + tags.map (tag => { + if (tag.id === targetId) + return { ...tag, children } + + if (tag.children.length === 0) + return tag + + return { ...tag, children: setChildrenById (tag.children, targetId, children) } + })) + + export default (() => { const [tags, setTags] = useState ([]) const [openTags, setOpenTags] = useState> ({ }) const [tagFetchedFlags, setTagFetchedFlags] = useState> ({ }) + useEffect (() => { + void (async () => { + setTags (await apiGet ('/tags/with-depth')) + }) () + }, []) + const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => ( - <> - {ts.map (t => ( - <> -
  • - { - e.preventDefault () - if (!(tagFetchedFlags[t.id])) - { - try - { - const data = - await apiGet ( - '/tags/with-depth', { params: { parent: t.id } }) - setTags (prev => { - const rtn = structuredClone (prev) - rtn.find (x => x.id === t.id)!.children = data - return rtn - }) - 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)} - ))} - ) + ts.map (t => ( + +
  • + +
  • + {openTags[t.id] && renderTags (t.children, nestLevel + 1)} +
    ))) return ( -
      - {renderTags (tags)} -
    +
    +
      + {renderTags (tags)} +
    +
    ) }) satisfies FC diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx new file mode 100644 index 0000000..0a0b635 --- /dev/null +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -0,0 +1,68 @@ +import { Fragment, useEffect, useState } from 'react' +import { Helmet } from 'react-helmet-async' +import { Link, useLocation } from 'react-router-dom' + +import MaterialSidebar from '@/components/MaterialSidebar' +import PageTitle from '@/components/common/PageTitle' +import SectionTitle from '@/components/common/SectionTitle' +import SubsectionTitle from '@/components/common/SubsectionTitle' +import MainArea from '@/components/layout/MainArea' +import { SITE_TITLE } from '@/config' +import { apiGet } from '@/lib/api' + +import type { FC } from 'react' + +import type { Tag } from '@/types' + +type TagWithMaterial = Omit & { + children: TagWithMaterial[] + material: any | null } + + + +export default (() => { + const [tag, setTag] = useState (null) + + const location = useLocation () + const query = new URLSearchParams (location.search) + const tagQuery = query.get ('tag') ?? '' + + useEffect (() => { + void (async () => { + setTag ( + await apiGet (`/tags/name/${ encodeURIComponent (tagQuery) }/materials`)) + }) () + }, [location.search]) + + return ( +
    + + {`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`} + + + + + + {tag + ? ( + <> + {tag.name} + {tag.children.map (c2 => ( + + {c2.name} + {c2.children.map (c3 => ( + {c3.name}))} + ))} + ) + : ( + <> +

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

    +

    もしくは……

    + + )} +
    +
    ) +}) satisfies FC