Browse Source

#99

feature/099
みてるぞ 1 week ago
parent
commit
6ed7f81151
10 changed files with 278 additions and 103 deletions
  1. +33
    -1
      backend/app/controllers/tags_controller.rb
  2. +1
    -0
      backend/config/routes.rb
  3. +4
    -0
      frontend/src/App.tsx
  4. +70
    -0
      frontend/src/components/MaterialSidebar.tsx
  5. +7
    -16
      frontend/src/components/TagLink.tsx
  6. +1
    -1
      frontend/src/components/TagSidebar.tsx
  7. +10
    -3
      frontend/src/components/TopNav.tsx
  8. +97
    -0
      frontend/src/components/common/TagInput.tsx
  9. +49
    -0
      frontend/src/pages/materials/MaterialSearchPage.tsx
  10. +6
    -82
      frontend/src/pages/posts/PostSearchPage.tsx

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

@@ -37,7 +37,7 @@ class TagsController < ApplicationController
q = q.where(posts: { id: post_id }) if post_id.present?

q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where(category: category) if category
q = q.where(category:) if category
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
@@ -69,6 +69,38 @@ class TagsController < ApplicationController
render json: { tags: TagRepr.base(tags), count: q.size }
end

def with_depth
parent_tag_id = params[:parent].to_i
parent_tag_id = nil if parent_tag_id <= 0

tag_ids =
if parent_tag_id
TagImplication.where(parent_tag_id:).select(:tag_id)
else
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
end

tags =
Tag
.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.where(id: tag_ids)
.order('tag_names.name')
.distinct
.to_a

has_children_tag_ids =
if tags.empty?
[]
else
TagImplication.where(parent_tag_id: tags.map(&:id)).distinct.pluck(:parent_tag_id)
end

render json: tags.map { |tag|
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
}
end

def autocomplete
q = params[:q].to_s.strip.sub(/\Anot:/i, '')



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

@@ -9,6 +9,7 @@ Rails.application.routes.draw do
resources :tags, only: [:index, :show, :update] do
collection do
get :autocomplete
get :'with-depth', action: :with_depth

scope :name do
get ':name/deerjikists', action: :deerjikists_by_name


+ 4
- 0
frontend/src/App.tsx View File

@@ -10,6 +10,8 @@ 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 NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import PostDetailPage from '@/pages/posts/PostDetailPage'
@@ -51,6 +53,8 @@ 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/search" element={<MaterialSearchPage/>}/>
{/* <Route path="/materials/:tagName" element ={<MaterialDetailPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>


+ 70
- 0
frontend/src/components/MaterialSidebar.tsx View File

@@ -0,0 +1,70 @@
import { useState } from 'react'

import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { apiGet } from '@/lib/api'

import type { FC, ReactNode } from 'react'

import type { Tag } from '@/types'

type TagWithDepth = Tag & {
hasChildren: boolean
children: TagWithDepth[] }


export default (() => {
const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })

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

return (
<SidebarComponent>
<ul>
{renderTags (tags)}
</ul>
</SidebarComponent>)
}) satisfies FC

+ 7
- 16
frontend/src/components/TagLink.tsx View File

@@ -13,8 +13,7 @@ type CommonProps = {
tag: Tag
nestLevel?: number
withWiki?: boolean
withCount?: boolean
prefetch?: boolean }
withCount?: boolean }

type PropsWithLink =
& CommonProps
@@ -36,7 +35,6 @@ export default (({ tag,
linkFlg = true,
withWiki = true,
withCount = true,
prefetch = false,
...props }: Props) => {
const [havingWiki, setHavingWiki] = useState (true)

@@ -108,19 +106,12 @@ export default (({ tag,
</>)}
{linkFlg
? (
prefetch
? <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>
: <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>)
<PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>)
: (
<span className={spanClass}
{...props}>


+ 1
- 1
frontend/src/components/TagSidebar.tsx View File

@@ -66,7 +66,7 @@ export default (({ posts, onClick }: Props) => {
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<motion.div layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} prefetch onClick={onClick}/>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>
</li>))) : [])}
</ul>


+ 10
- 3
frontend/src/components/TopNav.tsx View File

@@ -70,20 +70,27 @@ export default (({ user }: Props) => {
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '投稿追加', to: '/posts/new' },
{ name: '追加', to: '/posts/new' },
{ name: '履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'タグ一覧', to: '/tags', visible: true },
{ name: 'マスタ', to: '/tags' },
{ name: '別名タグ', to: '/tags/aliases', visible: false },
{ name: '上位タグ', to: '/tags/implications', visible: false },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
{ name: '素材集', to: '/materials', subMenu: [
{ name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search' },
{ name: '追加', to: '/materials/new' },
{ name: '履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: 'wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>第&thinsp;1&thinsp;会場</>, to: '/theatres/1' },
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
{ name: <>ニジカ放送局第&thinsp;1&thinsp;チャンネル</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }] },
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },


+ 97
- 0
frontend/src/components/common/TagInput.tsx View File

@@ -0,0 +1,97 @@
import { useState } from 'react'

import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api'

import type { FC, ChangeEvent, KeyboardEvent } from 'react'

import type { Tag } from '@/types'


type Props = {
value: string
setValue: (value: string) => void }

export default (({ value, setValue }: Props) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)

// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setValue (ev.target.value)

const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}

const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ')
parts[parts.length - 1] = tag.name
setValue (parts.join (' ') + ' ')
setSuggestions ([])
setActiveIndex (-1)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break

case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break

case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
break

case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
}

return (
<div className="relative">
<input
type="text"
value={value}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
}) satisfies FC<Props>

+ 49
- 0
frontend/src/pages/materials/MaterialSearchPage.tsx View File

@@ -0,0 +1,49 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'

import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'

import type { FC, FormEvent } from 'react'


export default (() => {
const [tagName, setTagName] = useState ('')
const [parentTagName, setParentTagName] = useState ('')

const handleSearch = (e: FormEvent) => {
e.preventDefault ()
}

return (
<MainArea>
<Helmet>
<title>素材集 | {SITE_TITLE}</title>
</Helmet>

<div className="max-w-xl">
<PageTitle>素材集</PageTitle>

<form onSubmit={handleSearch} className="space-y-2">
{/* タグ */}
<div>
<Label>タグ</Label>
<TagInput
value={tagName}
setValue={setTagName}/>
</div>

{/* 親タグ */}
<div>
<Label>親タグ</Label>
<TagInput
value={parentTagName}
setValue={setParentTagName}/>
</div>
</form>
</div>
</MainArea>)
}) satisfies FC

+ 6
- 82
frontend/src/pages/posts/PostSearchPage.tsx View File

@@ -7,24 +7,22 @@ import { useLocation, useNavigate } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink'
import TagSearchBox from '@/components/TagSearchBox'
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { dateString, originalCreatedAtString } from '@/lib/utils'

import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
import type { FC, FormEvent } from 'react'

import type { FetchPostsOrder,
FetchPostsOrderField,
FetchPostsParams,
Tag } from '@/types'
FetchPostsParams } from '@/types'


const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
@@ -57,14 +55,11 @@ export default (() => {
const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder

const [activeIndex, setActiveIndex] = useState (-1)
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const [tagsStr, setTagsStr] = useState ('')
const [title, setTitle] = useState ('')
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
@@ -103,58 +98,6 @@ export default (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])

// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setTagsStr (ev.target.value)

const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}

const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break

case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break

case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
break

case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
}

const search = async () => {
const qs = new URLSearchParams ()
setIf (qs, 'tags', tagsStr)
@@ -172,15 +115,6 @@ export default (() => {
navigate (`${ location.pathname }?${ qs.toString () }`)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = tagsStr.split (' ')
parts[parts.length - 1] = tag.name
setTagsStr (parts.join (' ') + ' ')
setSuggestions ([])
setActiveIndex (-1)
}

const handleSearch = (e: FormEvent) => {
e.preventDefault ()
search ()
@@ -223,21 +157,11 @@ export default (() => {
</div>

{/* タグ */}
<div className="relative">
<div>
<Label>タグ</Label>
<input
type="text"
<TagInput
value={tagsStr}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
setValue={setTagsStr}/>
<fieldset className="w-full my-2">
<label>検索区分:</label>
<label className="mx-2">


Loading…
Cancel
Save