Browse Source

#99

feature/099
みてるぞ 1 week ago
parent
commit
f576373c8f
5 changed files with 168 additions and 47 deletions
  1. +25
    -1
      backend/app/controllers/tags_controller.rb
  2. +1
    -0
      backend/config/routes.rb
  3. +3
    -1
      frontend/src/App.tsx
  4. +71
    -45
      frontend/src/components/MaterialSidebar.tsx
  5. +68
    -0
      frontend/src/pages/materials/MaterialListPage.tsx

+ 25
- 1
backend/app/controllers/tags_controller.rb View File

@@ -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

+ 1
- 0
backend/config/routes.rb View File

@@ -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


+ 3
- 1
frontend/src/App.tsx View File

@@ -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/>}/>


+ 71
- 45
frontend/src/components/MaterialSidebar.tsx View File

@@ -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] ? <>&minus;</> : '+'}
</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

+ 68
- 0
frontend/src/pages/materials/MaterialListPage.tsx View File

@@ -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

Loading…
Cancel
Save