Browse Source

#318

feature/318
みてるぞ 1 week ago
parent
commit
3da9b447d4
6 changed files with 100 additions and 32 deletions
  1. +10
    -4
      backend/app/controllers/tags_controller.rb
  2. +1
    -1
      frontend/src/App.tsx
  3. +13
    -6
      frontend/src/components/TopNav.tsx
  4. +17
    -1
      frontend/src/lib/prefetchers.ts
  5. +35
    -10
      frontend/src/pages/tags/TagDetailPage.tsx
  6. +24
    -10
      frontend/src/pages/tags/TagListPage.tsx

+ 10
- 4
backend/app/controllers/tags_controller.rb View File

@@ -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




+ 1
- 1
frontend/src/App.tsx View File

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


+ 13
- 6
frontend/src/components/TopNav.tsx View File

@@ -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)


+ 17
- 1
frontend/src/lib/prefetchers.ts View File

@@ -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> => {


+ 35
- 10
frontend/src/pages/tags/TagDetailPage.tsx View File

@@ -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>


+ 24
- 10
frontend/src/pages/tags/TagListPage.tsx View File

@@ -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">


Loading…
Cancel
Save