| @@ -84,6 +84,7 @@ class TagsController < ApplicationController | |||||
| Tag | Tag | ||||
| .joins(:tag_name) | .joins(:tag_name) | ||||
| .includes(:tag_name, tag_name: :wiki_page) | .includes(:tag_name, tag_name: :wiki_page) | ||||
| .where(category: [:meme, :character, :material]) | |||||
| .where(id: tag_ids) | .where(id: tag_ids) | ||||
| .order('tag_names.name') | .order('tag_names.name') | ||||
| .distinct | .distinct | ||||
| @@ -93,7 +94,12 @@ class TagsController < ApplicationController | |||||
| if tags.empty? | if tags.empty? | ||||
| [] | [] | ||||
| else | 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 | end | ||||
| render json: tags.map { |tag| | render json: tags.map { |tag| | ||||
| @@ -101,6 +107,18 @@ class TagsController < ApplicationController | |||||
| } | } | ||||
| end | 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 | def autocomplete | ||||
| q = params[:q].to_s.strip.sub(/\Anot:/i, '') | q = params[:q].to_s.strip.sub(/\Anot:/i, '') | ||||
| @@ -210,4 +228,10 @@ class TagsController < ApplicationController | |||||
| render json: TagRepr.base(tag) | render json: TagRepr.base(tag) | ||||
| end | end | ||||
| private | |||||
| def build_tag_children tag | |||||
| TagRepr.base(tag).merge(children: tag.children.map { build_tag_children(_1) }) | |||||
| end | |||||
| end | end | ||||
| @@ -13,6 +13,7 @@ Rails.application.routes.draw do | |||||
| scope :name do | scope :name do | ||||
| get ':name/deerjikists', action: :deerjikists_by_name | get ':name/deerjikists', action: :deerjikists_by_name | ||||
| get ':name/materials', action: :materials_by_name | |||||
| get ':name', action: :show_by_name | get ':name', action: :show_by_name | ||||
| end | end | ||||
| end | end | ||||
| @@ -10,8 +10,9 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' | |||||
| import TopNav from '@/components/TopNav' | import TopNav from '@/components/TopNav' | ||||
| import { Toaster } from '@/components/ui/toaster' | import { Toaster } from '@/components/ui/toaster' | ||||
| import { apiPost, isApiError } from '@/lib/api' | import { apiPost, isApiError } from '@/lib/api' | ||||
| import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' | |||||
| // import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' | // 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 NicoTagListPage from '@/pages/tags/NicoTagListPage' | ||||
| import NotFound from '@/pages/NotFound' | import NotFound from '@/pages/NotFound' | ||||
| import PostDetailPage from '@/pages/posts/PostDetailPage' | import PostDetailPage from '@/pages/posts/PostDetailPage' | ||||
| @@ -53,6 +54,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||||
| <Route path="/tags" element={<TagListPage/>}/> | <Route path="/tags" element={<TagListPage/>}/> | ||||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | ||||
| <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | ||||
| <Route path="/materials" element={<MaterialListPage/>}/> | |||||
| <Route path="/materials/search" element={<MaterialSearchPage/>}/> | <Route path="/materials/search" element={<MaterialSearchPage/>}/> | ||||
| {/* <Route path="/materials/:tagName" element ={<MaterialDetailPage/>}/> */} | {/* <Route path="/materials/:tagName" element ={<MaterialDetailPage/>}/> */} | ||||
| <Route path="/wiki" element={<WikiSearchPage/>}/> | <Route path="/wiki" element={<WikiSearchPage/>}/> | ||||
| @@ -1,4 +1,4 @@ | |||||
| import { useState } from 'react' | |||||
| import { Fragment, useEffect, useState } from 'react' | |||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| @@ -13,58 +13,84 @@ type TagWithDepth = Tag & { | |||||
| children: TagWithDepth[] } | 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 (() => { | export default (() => { | ||||
| const [tags, setTags] = useState<TagWithDepth[]> ([]) | const [tags, setTags] = useState<TagWithDepth[]> ([]) | ||||
| const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) | const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) | ||||
| const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ }) | const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ }) | ||||
| useEffect (() => { | |||||
| void (async () => { | |||||
| setTags (await apiGet<TagWithDepth[]> ('/tags/with-depth')) | |||||
| }) () | |||||
| }, []) | |||||
| const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => ( | const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => ( | ||||
| <> | |||||
| {ts.map (t => ( | |||||
| <> | |||||
| <li key={t.id}> | |||||
| <a | |||||
| href="#" | |||||
| onClick={async e => { | |||||
| e.preventDefault () | |||||
| if (!(tagFetchedFlags[t.id])) | |||||
| { | |||||
| try | |||||
| { | |||||
| const data = | |||||
| await apiGet<TagWithDepth[]> ( | |||||
| '/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] ? '-' : '+'} | |||||
| </a> | |||||
| <TagLink | |||||
| tag={t} | |||||
| nestLevel={nestLevel} | |||||
| withCount={false} | |||||
| withWiki={false} | |||||
| to={`/materials?tag=${ encodeURIComponent (t.name) }`}/> | |||||
| </li> | |||||
| {openTags[t.id] && renderTags (t.children, nestLevel + 1)} | |||||
| </>))} | |||||
| </>) | |||||
| ts.map (t => ( | |||||
| <Fragment key={t.id}> | |||||
| <li> | |||||
| <div className="flex"> | |||||
| <div className="flex-none w-4"> | |||||
| {t.hasChildren && ( | |||||
| <a | |||||
| href="#" | |||||
| onClick={async e => { | |||||
| e.preventDefault () | |||||
| if (!(tagFetchedFlags[t.id])) | |||||
| { | |||||
| try | |||||
| { | |||||
| const data = | |||||
| await apiGet<TagWithDepth[]> ( | |||||
| '/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] ? <>−</> : '+'} | |||||
| </a>)} | |||||
| </div> | |||||
| <div className="flex-1 truncate"> | |||||
| <TagLink | |||||
| tag={t} | |||||
| nestLevel={nestLevel} | |||||
| title={t.name} | |||||
| withCount={false} | |||||
| withWiki={false} | |||||
| to={`/materials?tag=${ encodeURIComponent (t.name) }`}/> | |||||
| </div> | |||||
| </div> | |||||
| </li> | |||||
| {openTags[t.id] && renderTags (t.children, nestLevel + 1)} | |||||
| </Fragment>))) | |||||
| return ( | return ( | ||||
| <SidebarComponent> | <SidebarComponent> | ||||
| <ul> | |||||
| {renderTags (tags)} | |||||
| </ul> | |||||
| <div className="md:h-[calc(100dvh-120px)] md:overflow-y-auto"> | |||||
| <ul> | |||||
| {renderTags (tags)} | |||||
| </ul> | |||||
| </div> | |||||
| </SidebarComponent>) | </SidebarComponent>) | ||||
| }) satisfies FC | }) satisfies FC | ||||
| @@ -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<Tag, 'children'> & { | |||||
| children: TagWithMaterial[] | |||||
| material: any | null } | |||||
| export default (() => { | |||||
| const [tag, setTag] = useState<TagWithMaterial | null> (null) | |||||
| const location = useLocation () | |||||
| const query = new URLSearchParams (location.search) | |||||
| const tagQuery = query.get ('tag') ?? '' | |||||
| useEffect (() => { | |||||
| void (async () => { | |||||
| setTag ( | |||||
| await apiGet<TagWithMaterial> (`/tags/name/${ encodeURIComponent (tagQuery) }/materials`)) | |||||
| }) () | |||||
| }, [location.search]) | |||||
| return ( | |||||
| <div className="md:flex md:flex-1"> | |||||
| <Helmet> | |||||
| <title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title> | |||||
| </Helmet> | |||||
| <MaterialSidebar/> | |||||
| <MainArea> | |||||
| {tag | |||||
| ? ( | |||||
| <> | |||||
| <PageTitle>{tag.name}</PageTitle> | |||||
| {tag.children.map (c2 => ( | |||||
| <Fragment key={c2.id}> | |||||
| <SectionTitle>{c2.name}</SectionTitle> | |||||
| {c2.children.map (c3 => ( | |||||
| <SubsectionTitle key={c3.id}>{c3.name}</SubsectionTitle>))} | |||||
| </Fragment>))} | |||||
| </>) | |||||
| : ( | |||||
| <> | |||||
| <p>左のリストから照会したいタグを選択してください。</p> | |||||
| <p>もしくは……</p> | |||||
| <ul> | |||||
| <li><Link to="/materials/new">素材を新規追加する</Link></li> | |||||
| <li><a href="#">すべての素材をダウンロードする</a></li> | |||||
| </ul> | |||||
| </>)} | |||||
| </MainArea> | |||||
| </div>) | |||||
| }) satisfies FC | |||||