This commit is contained in:
@@ -37,7 +37,7 @@ class TagsController < ApplicationController
|
|||||||
q = q.where(posts: { id: post_id }) if post_id.present?
|
q = q.where(posts: { id: post_id }) if post_id.present?
|
||||||
|
|
||||||
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
|
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[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.post_count <= ?', post_count_between[1]) if post_count_between[1]
|
||||||
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
|
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 }
|
render json: { tags: TagRepr.base(tags), count: q.size }
|
||||||
end
|
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
|
def autocomplete
|
||||||
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
|
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Rails.application.routes.draw do
|
|||||||
resources :tags, only: [:index, :show, :update] do
|
resources :tags, only: [:index, :show, :update] do
|
||||||
collection do
|
collection do
|
||||||
get :autocomplete
|
get :autocomplete
|
||||||
|
get :'with-depth', action: :with_depth
|
||||||
|
|
||||||
scope :name do
|
scope :name do
|
||||||
get ':name/deerjikists', action: :deerjikists_by_name
|
get ':name/deerjikists', action: :deerjikists_by_name
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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 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'
|
||||||
@@ -51,6 +53,8 @@ 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/search" element={<MaterialSearchPage/>}/>
|
||||||
|
{/* <Route path="/materials/:tagName" element ={<MaterialDetailPage/>}/> */}
|
||||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,8 +13,7 @@ type CommonProps = {
|
|||||||
tag: Tag
|
tag: Tag
|
||||||
nestLevel?: number
|
nestLevel?: number
|
||||||
withWiki?: boolean
|
withWiki?: boolean
|
||||||
withCount?: boolean
|
withCount?: boolean }
|
||||||
prefetch?: boolean }
|
|
||||||
|
|
||||||
type PropsWithLink =
|
type PropsWithLink =
|
||||||
& CommonProps
|
& CommonProps
|
||||||
@@ -36,7 +35,6 @@ export default (({ tag,
|
|||||||
linkFlg = true,
|
linkFlg = true,
|
||||||
withWiki = true,
|
withWiki = true,
|
||||||
withCount = true,
|
withCount = true,
|
||||||
prefetch = false,
|
|
||||||
...props }: Props) => {
|
...props }: Props) => {
|
||||||
const [havingWiki, setHavingWiki] = useState (true)
|
const [havingWiki, setHavingWiki] = useState (true)
|
||||||
|
|
||||||
@@ -108,19 +106,12 @@ export default (({ tag,
|
|||||||
</>)}
|
</>)}
|
||||||
{linkFlg
|
{linkFlg
|
||||||
? (
|
? (
|
||||||
prefetch
|
<PrefetchLink
|
||||||
? <PrefetchLink
|
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
||||||
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
className={linkClass}
|
||||||
className={linkClass}
|
{...props}>
|
||||||
{...props}>
|
{tag.name}
|
||||||
{tag.name}
|
</PrefetchLink>)
|
||||||
</PrefetchLink>
|
|
||||||
: <PrefetchLink
|
|
||||||
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
|
||||||
className={linkClass}
|
|
||||||
{...props}>
|
|
||||||
{tag.name}
|
|
||||||
</PrefetchLink>)
|
|
||||||
: (
|
: (
|
||||||
<span className={spanClass}
|
<span className={spanClass}
|
||||||
{...props}>
|
{...props}>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default (({ posts, onClick }: Props) => {
|
|||||||
tags[cat].map (tag => (
|
tags[cat].map (tag => (
|
||||||
<li key={tag.id} className="mb-1">
|
<li key={tag.id} className="mb-1">
|
||||||
<motion.div layoutId={`tag-${ tag.id }`}>
|
<motion.div layoutId={`tag-${ tag.id }`}>
|
||||||
<TagLink tag={tag} prefetch onClick={onClick}/>
|
<TagLink tag={tag} onClick={onClick}/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</li>))) : [])}
|
</li>))) : [])}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -70,20 +70,27 @@ export default (({ user }: Props) => {
|
|||||||
{ name: '広場', to: '/posts', subMenu: [
|
{ name: '広場', to: '/posts', subMenu: [
|
||||||
{ name: '一覧', to: '/posts' },
|
{ name: '一覧', to: '/posts' },
|
||||||
{ name: '検索', to: '/posts/search' },
|
{ name: '検索', to: '/posts/search' },
|
||||||
{ name: '投稿追加', to: '/posts/new' },
|
{ name: '追加', to: '/posts/new' },
|
||||||
{ name: '履歴', to: '/posts/changes' },
|
{ name: '履歴', to: '/posts/changes' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||||
{ name: 'タグ', to: '/tags', subMenu: [
|
{ name: 'タグ', to: '/tags', subMenu: [
|
||||||
{ name: 'タグ一覧', to: '/tags', visible: true },
|
{ name: 'マスタ', to: '/tags' },
|
||||||
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
||||||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
||||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
{ 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: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
|
||||||
{ name: <>第 1 会場</>, to: '/theatres/1' },
|
{ name: <>第 1 会場</>, to: '/theatres/1' },
|
||||||
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
|
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
|
||||||
{ name: <>ニジカ放送局第 1 チャンネル</>,
|
{ name: <>ニジカ放送局第 1 チャンネル</>,
|
||||||
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: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||||||
{ name: '検索', to: '/wiki' },
|
{ name: '検索', to: '/wiki' },
|
||||||
{ name: '新規', to: '/wiki/new' },
|
{ name: '新規', to: '/wiki/new' },
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -7,24 +7,22 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import SortHeader from '@/components/SortHeader'
|
import SortHeader from '@/components/SortHeader'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
import Label from '@/components/common/Label'
|
import Label from '@/components/common/Label'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import Pagination from '@/components/common/Pagination'
|
import Pagination from '@/components/common/Pagination'
|
||||||
|
import TagInput from '@/components/common/TagInput'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiGet } from '@/lib/api'
|
|
||||||
import { fetchPosts } from '@/lib/posts'
|
import { fetchPosts } from '@/lib/posts'
|
||||||
import { postsKeys } from '@/lib/queryKeys'
|
import { postsKeys } from '@/lib/queryKeys'
|
||||||
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
|
import type { FC, FormEvent } from 'react'
|
||||||
|
|
||||||
import type { FetchPostsOrder,
|
import type { FetchPostsOrder,
|
||||||
FetchPostsOrderField,
|
FetchPostsOrderField,
|
||||||
FetchPostsParams,
|
FetchPostsParams } from '@/types'
|
||||||
Tag } from '@/types'
|
|
||||||
|
|
||||||
|
|
||||||
const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
|
const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
|
||||||
@@ -57,14 +55,11 @@ export default (() => {
|
|||||||
const qUpdatedTo = query.get ('updated_to') ?? ''
|
const qUpdatedTo = query.get ('updated_to') ?? ''
|
||||||
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
|
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
|
||||||
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
|
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
|
||||||
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
||||||
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
|
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
|
||||||
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
||||||
const [originalCreatedTo, setOriginalCreatedTo] = 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 [tagsStr, setTagsStr] = useState ('')
|
||||||
const [title, setTitle] = useState ('')
|
const [title, setTitle] = useState ('')
|
||||||
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
||||||
@@ -103,58 +98,6 @@ export default (() => {
|
|||||||
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
||||||
}, [location.search])
|
}, [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 search = async () => {
|
||||||
const qs = new URLSearchParams ()
|
const qs = new URLSearchParams ()
|
||||||
setIf (qs, 'tags', tagsStr)
|
setIf (qs, 'tags', tagsStr)
|
||||||
@@ -172,15 +115,6 @@ export default (() => {
|
|||||||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
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) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
search ()
|
search ()
|
||||||
@@ -223,21 +157,11 @@ export default (() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* タグ */}
|
{/* タグ */}
|
||||||
<div className="relative">
|
<div>
|
||||||
<Label>タグ</Label>
|
<Label>タグ</Label>
|
||||||
<input
|
<TagInput
|
||||||
type="text"
|
|
||||||
value={tagsStr}
|
value={tagsStr}
|
||||||
onChange={whenChanged}
|
setValue={setTagsStr}/>
|
||||||
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}/>
|
|
||||||
<fieldset className="w-full my-2">
|
<fieldset className="w-full my-2">
|
||||||
<label>検索区分:</label>
|
<label>検索区分:</label>
|
||||||
<label className="mx-2">
|
<label className="mx-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user