| @@ -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 | |||||
| prefetch?: boolean } | |||||
| withCount?: 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 | |||||
| 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} | <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, | |||||
| Tag } from '@/types' | |||||
| FetchPostsParams } 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 | |||||
| type="text" | |||||
| <TagInput | |||||
| value={tagsStr} | 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"> | <fieldset className="w-full my-2"> | ||||
| <label>検索区分:</label> | <label>検索区分:</label> | ||||
| <label className="mx-2"> | <label className="mx-2"> | ||||