| @@ -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 | |||
| @@ -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 | |||
| @@ -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 }: { | |||
| <Route path="/tags" element={<TagListPage/>}/> | |||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | |||
| <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | |||
| <Route path="/materials" element={<MaterialListPage/>}/> | |||
| <Route path="/materials/search" element={<MaterialSearchPage/>}/> | |||
| {/* <Route path="/materials/:tagName" element ={<MaterialDetailPage/>}/> */} | |||
| <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 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<TagWithDepth[]> ([]) | |||
| const [openTags, setOpenTags] = 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 => ( | |||
| <> | |||
| {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 ( | |||
| <SidebarComponent> | |||
| <ul> | |||
| {renderTags (tags)} | |||
| </ul> | |||
| <div className="md:h-[calc(100dvh-120px)] md:overflow-y-auto"> | |||
| <ul> | |||
| {renderTags (tags)} | |||
| </ul> | |||
| </div> | |||
| </SidebarComponent>) | |||
| }) 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 | |||