| @@ -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, '') | |||
| @@ -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 | |||
| @@ -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}/>}/> | |||
| @@ -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 | |||
| 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}> | |||
| @@ -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> | |||
| @@ -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: <>第 1 会場</>, to: '/theatres/1' }, | |||
| { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' }, | |||
| { 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: '検索', to: '/wiki' }, | |||
| { 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 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"> | |||