| @@ -220,8 +220,14 @@ class TagsController < ApplicationController | |||||
| category = params[:category].to_s.strip | category = params[:category].to_s.strip | ||||
| return head :unprocessable_entity if name.blank? || category.blank? | return head :unprocessable_entity if name.blank? || category.blank? | ||||
| if tag.nico? != (category == 'nico') | |||||
| return render json: { error: 'ニコタグのカテゴリ変更はできません.' }, | |||||
| if name != tag.name && | |||||
| tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) | |||||
| return render json: { error: 'システム・タグの名称は変更できません.' }, | |||||
| status: :unprocessable_entity | |||||
| end | |||||
| if tag.nico? || category == 'nico' | |||||
| return render json: { error: 'ニコタグは変更できません.' }, | |||||
| status: :unprocessable_entity | status: :unprocessable_entity | ||||
| end | end | ||||
| @@ -258,8 +264,8 @@ class TagsController < ApplicationController | |||||
| tag = Tag.find(params[:id]) | tag = Tag.find(params[:id]) | ||||
| if category.present? && tag.nico? != (category == 'nico') | |||||
| return render json: { error: 'ニコタグのカテゴリ変更はできません.' }, | |||||
| if tag.nico? || (category.present? && category == 'nico') | |||||
| return render json: { error: 'ニコタグは変更できません.' }, | |||||
| status: :unprocessable_entity | status: :unprocessable_entity | ||||
| end | end | ||||
| @@ -56,7 +56,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||||
| <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | ||||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | <Route path="/posts/changes" element={<PostHistoryPage/>}/> | ||||
| <Route path="/tags" element={<TagListPage/>}/> | <Route path="/tags" element={<TagListPage/>}/> | ||||
| <Route path="/tags/:name" element={<TagDetailPage/>}/> | |||||
| <Route path="/tags/:id" element={<TagDetailPage/>}/> | |||||
| <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={<MaterialBasePage/>}> | <Route path="/materials" element={<MaterialBasePage/>}> | ||||
| @@ -8,7 +8,7 @@ import PrefetchLink from '@/components/PrefetchLink' | |||||
| import TopNavUser from '@/components/TopNavUser' | import TopNavUser from '@/components/TopNavUser' | ||||
| import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | ||||
| import { tagsKeys, wikiKeys } from '@/lib/queryKeys' | import { tagsKeys, wikiKeys } from '@/lib/queryKeys' | ||||
| import { fetchTagByName } from '@/lib/tags' | |||||
| import { fetchTag, fetchTagByName } from '@/lib/tags' | |||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import { fetchWikiPage } from '@/lib/wiki' | import { fetchWikiPage } from '@/lib/wiki' | ||||
| @@ -29,6 +29,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId) | const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId) | ||||
| const wikiTitle = pathName.split ('/')[2] ?? '' | const wikiTitle = pathName.split ('/')[2] ?? '' | ||||
| const tagFlg = /^\/tags\/\d+/.test (pathName) | |||||
| return [ | return [ | ||||
| { name: '広場', to: '/posts', subMenu: [ | { name: '広場', to: '/posts', subMenu: [ | ||||
| { name: '一覧', to: '/posts' }, | { name: '一覧', to: '/posts' }, | ||||
| @@ -38,10 +40,13 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | ||||
| { name: 'タグ', to: '/tags', subMenu: [ | { name: 'タグ', to: '/tags', subMenu: [ | ||||
| { name: 'マスタ', to: '/tags' }, | { name: 'マスタ', to: '/tags' }, | ||||
| { name: '別名タグ', to: '/tags/aliases', visible: false }, | |||||
| { name: '上位タグ', to: '/tags/implications', visible: false }, | |||||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | { name: 'ニコニコ連携', to: '/tags/nico' }, | ||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, | |||||
| { component: <Separator/>, visible: tagFlg }, | |||||
| { name: `広場 (${ postCount || 0 })`, | |||||
| to: `/posts?tags=${ encodeURIComponent (tag?.name) }`, | |||||
| visible: tagFlg }, | |||||
| { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, visible: false }] }, | |||||
| { name: '素材', to: '/materials', visible: false, subMenu: [ | { name: '素材', to: '/materials', visible: false, subMenu: [ | ||||
| { name: '一覧', to: '/materials' }, | { name: '一覧', to: '/materials' }, | ||||
| { name: '検索', to: '/materials/search', visible: false }, | { name: '検索', to: '/materials/search', visible: false }, | ||||
| @@ -114,12 +119,14 @@ export default (({ user }: Props) => { | |||||
| queryKey: wikiKeys.show (wikiIdStr, { }), | queryKey: wikiKeys.show (wikiIdStr, { }), | ||||
| queryFn: () => fetchWikiPage (wikiIdStr, { }) }) | queryFn: () => fetchWikiPage (wikiIdStr, { }) }) | ||||
| const effectiveTitle = wikiPage?.title ?? '' | |||||
| const tagFlg = /^\/tags\/\d+/.test (location.pathname) | |||||
| const effectiveTitle = (tagFlg ? location.pathname.split ('/')[2] : wikiPage?.title) ?? '' | |||||
| const { data: tag } = useQuery ({ | const { data: tag } = useQuery ({ | ||||
| enabled: Boolean (effectiveTitle), | enabled: Boolean (effectiveTitle), | ||||
| queryKey: tagsKeys.show (effectiveTitle), | queryKey: tagsKeys.show (effectiveTitle), | ||||
| queryFn: () => fetchTagByName (effectiveTitle) }) | |||||
| queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) }) | |||||
| const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) | const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) | ||||
| const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) | const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) | ||||
| @@ -14,6 +14,7 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> | |||||
| const mPost = match<{ id: string }> ('/posts/:id') | const mPost = match<{ id: string }> ('/posts/:id') | ||||
| const mWiki = match<{ title: string }> ('/wiki/:title') | const mWiki = match<{ title: string }> ('/wiki/:title') | ||||
| const mTag = match<{ id: string }> ('/tags/:id') | |||||
| const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { | const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { | ||||
| @@ -169,6 +170,19 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => { | |||||
| } | } | ||||
| const prefetchTagShow: Prefetcher = async (qc, url) => { | |||||
| const m = mTag (url.pathname) | |||||
| if (!(m)) | |||||
| return | |||||
| const { id } = m.params | |||||
| await qc.prefetchQuery ({ | |||||
| queryKey: tagsKeys.show (id), | |||||
| queryFn: () => fetchTag (id) }) | |||||
| } | |||||
| export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ | export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ | ||||
| { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), | { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), | ||||
| run: prefetchPostsIndex }, | run: prefetchPostsIndex }, | ||||
| @@ -180,7 +194,9 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] | |||||
| { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) | { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) | ||||
| && Boolean (mWiki (u.pathname))), | && Boolean (mWiki (u.pathname))), | ||||
| run: prefetchWikiPageShow }, | run: prefetchWikiPageShow }, | ||||
| { test: u => u.pathname === '/tags', run: prefetchTagsIndex }] | |||||
| { test: u => u.pathname === '/tags', run: prefetchTagsIndex }, | |||||
| { test: u => u.pathname !== '/tags/nico' && Boolean (mTag (u.pathname)), | |||||
| run: prefetchTagShow }] | |||||
| export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | ||||
| @@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { useParams } from 'react-router-dom' | import { useParams } from 'react-router-dom' | ||||
| import TagLink from '@/components/TagLink' | |||||
| 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 MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| @@ -9,7 +10,8 @@ import { toast } from '@/components/ui/use-toast' | |||||
| import { CATEGORIES, CATEGORY_NAMES } from '@/consts' | import { CATEGORIES, CATEGORY_NAMES } from '@/consts' | ||||
| import { apiPut } from '@/lib/api' | import { apiPut } from '@/lib/api' | ||||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | import { postsKeys, tagsKeys } from '@/lib/queryKeys' | ||||
| import { fetchTagByName } from '@/lib/tags' | |||||
| import { fetchTag } from '@/lib/tags' | |||||
| import { cn } from '@/lib/utils' | |||||
| import type { FC, FormEvent } from 'react' | import type { FC, FormEvent } from 'react' | ||||
| @@ -17,18 +19,19 @@ import type { Category, Tag } from '@/types' | |||||
| export default (() => { | export default (() => { | ||||
| const { name: nameRaw } = useParams () | |||||
| const tagName = String (nameRaw ?? '') | |||||
| const tagKey = tagsKeys.show (tagName) | |||||
| const { id } = useParams () | |||||
| const tagId = String (id ?? '') | |||||
| const tagKey = tagsKeys.show (tagId) | |||||
| const { data: tag, isLoading: loading } = useQuery ({ | const { data: tag, isLoading: loading } = useQuery ({ | ||||
| queryKey: tagKey, | queryKey: tagKey, | ||||
| queryFn: () => fetchTagByName (tagName) }) | |||||
| queryFn: () => fetchTag (tagId) }) | |||||
| const [name, setName] = useState ('') | const [name, setName] = useState ('') | ||||
| const [category, setCategory] = useState<Category> ('general') | const [category, setCategory] = useState<Category> ('general') | ||||
| const [aliases, setAliases] = useState ('') | const [aliases, setAliases] = useState ('') | ||||
| const [parentTags, setParentTags] = useState ('') | const [parentTags, setParentTags] = useState ('') | ||||
| const [disabled, setDisabled] = useState (true) | |||||
| const qc = useQueryClient () | const qc = useQueryClient () | ||||
| @@ -43,7 +46,8 @@ export default (() => { | |||||
| try | try | ||||
| { | { | ||||
| const data = await apiPut<Tag> (`/tags/${ tag?.id }`, formData) | |||||
| const data = await apiPut<Tag> (`/tags/${ id }`, formData) | |||||
| setName (data.name) | setName (data.name) | ||||
| setCategory (data.category as Category) | setCategory (data.category as Category) | ||||
| setAliases (data.aliases.join (' ')) | setAliases (data.aliases.join (' ')) | ||||
| @@ -61,26 +65,37 @@ export default (() => { | |||||
| useEffect (() => { | useEffect (() => { | ||||
| if (!(tag)) | if (!(tag)) | ||||
| return | |||||
| { | |||||
| setDisabled (true) | |||||
| return | |||||
| } | |||||
| setName (tag.name) | setName (tag.name) | ||||
| setCategory (tag.category as Category) | setCategory (tag.category as Category) | ||||
| setAliases (tag.aliases?.join (' ')) | setAliases (tag.aliases?.join (' ')) | ||||
| setParentTags (tag.parents?.map (t => t.name).join (' ')) | setParentTags (tag.parents?.map (t => t.name).join (' ')) | ||||
| setDisabled (tag.category === 'nico') | |||||
| }, [tag]) | }, [tag]) | ||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| {(loading || !(tag)) ? 'Loading...' : ( | {(loading || !(tag)) ? 'Loading...' : ( | ||||
| <div className="max-w-xl"> | <div className="max-w-xl"> | ||||
| <PageTitle>{tag.name}</PageTitle> | |||||
| <PageTitle> | |||||
| <TagLink | |||||
| tag={tag} | |||||
| withWiki={false} | |||||
| withCount={false}/> | |||||
| </PageTitle> | |||||
| <form onSubmit={handleSubmit} className="my-4 space-y-2"> | <form onSubmit={handleSubmit} className="my-4 space-y-2"> | ||||
| {/* 名称 */} | {/* 名称 */} | ||||
| <div> | <div> | ||||
| <Label>名称</Label> | <Label>名称</Label> | ||||
| {/* TODO: 補完に対応させる */} | |||||
| <input | <input | ||||
| type="text" | type="text" | ||||
| disabled={disabled} | |||||
| value={name} | value={name} | ||||
| onChange={e => setName (e.target.value)} | onChange={e => setName (e.target.value)} | ||||
| className="w-full border p-2 rounded"/> | className="w-full border p-2 rounded"/> | ||||
| @@ -90,10 +105,12 @@ export default (() => { | |||||
| <div> | <div> | ||||
| <Label>カテゴリ</Label> | <Label>カテゴリ</Label> | ||||
| <select | <select | ||||
| disabled={disabled} | |||||
| value={category ?? ''} | value={category ?? ''} | ||||
| onChange={e => setCategory(e.target.value as Category)} | onChange={e => setCategory(e.target.value as Category)} | ||||
| className="w-full border p-2 rounded"> | className="w-full border p-2 rounded"> | ||||
| {CATEGORIES.filter (cat => cat !== 'nico').map (cat => ( | |||||
| {CATEGORIES.filter (cat => tag.category === 'nico' || cat !== 'nico') | |||||
| .map (cat => ( | |||||
| <option key={cat} value={cat}> | <option key={cat} value={cat}> | ||||
| {CATEGORY_NAMES[cat]} | {CATEGORY_NAMES[cat]} | ||||
| </option>))} | </option>))} | ||||
| @@ -103,8 +120,10 @@ export default (() => { | |||||
| {/* 別名 */} | {/* 別名 */} | ||||
| <div> | <div> | ||||
| <Label>別名</Label> | <Label>別名</Label> | ||||
| {/* TODO: 補完に対応させる */} | |||||
| <input | <input | ||||
| type="text" | type="text" | ||||
| disabled={disabled} | |||||
| value={aliases} | value={aliases} | ||||
| onChange={e => setAliases (e.target.value)} | onChange={e => setAliases (e.target.value)} | ||||
| className="w-full border p-2 rounded"/> | className="w-full border p-2 rounded"/> | ||||
| @@ -113,8 +132,10 @@ export default (() => { | |||||
| {/* 上位タグ */} | {/* 上位タグ */} | ||||
| <div> | <div> | ||||
| <Label>上位タグ</Label> | <Label>上位タグ</Label> | ||||
| {/* TODO: 補完に対応させる */} | |||||
| <input | <input | ||||
| type="text" | type="text" | ||||
| disabled={disabled} | |||||
| value={parentTags} | value={parentTags} | ||||
| onChange={e => setParentTags (e.target.value)} | onChange={e => setParentTags (e.target.value)} | ||||
| className="w-full border p-2 rounded"/> | className="w-full border p-2 rounded"/> | ||||
| @@ -123,7 +144,11 @@ export default (() => { | |||||
| <div className="py-3"> | <div className="py-3"> | ||||
| <button | <button | ||||
| type="submit" | type="submit" | ||||
| className="bg-blue-500 text-white px-4 py-2 rounded"> | |||||
| disabled={disabled} | |||||
| className={cn ('px-4 py-2 rounded', | |||||
| (disabled | |||||
| ? 'text-gray-300 bg-gray-500' | |||||
| : 'text-white bg-blue-500'))}> | |||||
| 更新 | 更新 | ||||
| </button> | </button> | ||||
| </div> | </div> | ||||
| @@ -205,13 +205,15 @@ export default (() => { | |||||
| {loading ? 'Loading...' : (results.length > 0 ? ( | {loading ? 'Loading...' : (results.length > 0 ? ( | ||||
| <div className="mt-4"> | <div className="mt-4"> | ||||
| <div className="overflow-x-auto"> | <div className="overflow-x-auto"> | ||||
| <table className="w-full min-w-[1200px] table-fixed border-collapse"> | |||||
| <table className="w-full min-w-[2000px] table-fixed border-collapse"> | |||||
| <colgroup> | <colgroup> | ||||
| <col className="w-72"/> | <col className="w-72"/> | ||||
| <col className="w-48"/> | |||||
| <col className="w-16"/> | <col className="w-16"/> | ||||
| <col className="w-44"/> | |||||
| <col className="w-44"/> | |||||
| <col className="w-48"/> | |||||
| <col className="w-72"/> | |||||
| <col className="w-48"/> | |||||
| <col className="w-56"/> | |||||
| <col className="w-56"/> | |||||
| <col className="w-16"/> | <col className="w-16"/> | ||||
| </colgroup> | </colgroup> | ||||
| @@ -226,18 +228,20 @@ export default (() => { | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader<FetchTagsOrderField> | <SortHeader<FetchTagsOrderField> | ||||
| by="category" | |||||
| label="カテゴリ" | |||||
| by="post_count" | |||||
| label="件数" | |||||
| currentOrder={order} | currentOrder={order} | ||||
| defaultDirection={defaultDirection}/> | defaultDirection={defaultDirection}/> | ||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader<FetchTagsOrderField> | <SortHeader<FetchTagsOrderField> | ||||
| by="post_count" | |||||
| label="件数" | |||||
| by="category" | |||||
| label="カテゴリ" | |||||
| currentOrder={order} | currentOrder={order} | ||||
| defaultDirection={defaultDirection}/> | defaultDirection={defaultDirection}/> | ||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap">別名</th> | |||||
| <th className="p-2 text-left whitespace-nowrap">上位タグ</th> | |||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader<FetchTagsOrderField> | <SortHeader<FetchTagsOrderField> | ||||
| by="created_at" | by="created_at" | ||||
| @@ -262,11 +266,21 @@ export default (() => { | |||||
| <td className="p-2"> | <td className="p-2"> | ||||
| <TagLink | <TagLink | ||||
| tag={row} | tag={row} | ||||
| to={`/tags/${ encodeURIComponent (row.name) }`} | |||||
| to={`/tags/${ encodeURIComponent (row.id) }`} | |||||
| withCount={false}/> | withCount={false}/> | ||||
| </td> | </td> | ||||
| <td className="p-2">{CATEGORY_NAMES[row.category]}</td> | |||||
| <td className="p-2 text-right">{row.postCount}</td> | <td className="p-2 text-right">{row.postCount}</td> | ||||
| <td className="p-2">{CATEGORY_NAMES[row.category]}</td> | |||||
| <td className="p-2">{row.aliases.join (' ')}</td> | |||||
| <td className="p-2"> | |||||
| {row.parents.map (t => ( | |||||
| <span key={t.id} className="mr-2"> | |||||
| <TagLink | |||||
| tag={t} | |||||
| withWiki={false} | |||||
| withCount={false}/> | |||||
| </span>))} | |||||
| </td> | |||||
| <td className="p-2">{dateString (row.createdAt)}</td> | <td className="p-2">{dateString (row.createdAt)}</td> | ||||
| <td className="p-2">{dateString (row.updatedAt)}</td> | <td className="p-2">{dateString (row.updatedAt)}</td> | ||||
| <td className="p-2"> | <td className="p-2"> | ||||