Browse Source

タグ詳細 (#318) (#328)

#318

#318

#318

#318

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/328
feature/321
みてるぞ 1 week ago
parent
commit
43cd38a216
11 changed files with 639 additions and 46 deletions
  1. +85
    -5
      backend/app/controllers/tags_controller.rb
  2. +3
    -4
      backend/app/representations/tag_repr.rb
  3. +4
    -1
      backend/config/routes.rb
  4. +319
    -12
      backend/spec/requests/tags_spec.rb
  5. +2
    -0
      frontend/src/App.tsx
  6. +13
    -6
      frontend/src/components/TopNav.tsx
  7. +17
    -1
      frontend/src/lib/prefetchers.ts
  8. +158
    -0
      frontend/src/pages/tags/TagDetailPage.tsx
  9. +27
    -10
      frontend/src/pages/tags/TagListPage.tsx
  10. +9
    -7
      frontend/src/pages/wiki/WikiDetailPage.tsx
  11. +2
    -0
      frontend/src/types.ts

+ 85
- 5
backend/app/controllers/tags_controller.rb View File

@@ -66,7 +66,7 @@ class TagsController < ApplicationController
.offset(offset)
.to_a

render json: { tags: TagRepr.base(tags), count: q.size }
render json: { tags: TagRepr.many(tags), count: q.size }
end

def with_depth
@@ -209,6 +209,52 @@ class TagsController < ApplicationController
render json: build_tag_children(tag)
end

def update_all
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?

tag = Tag.find_by(id: params[:id])
return head :not_found unless tag

name = params[:name].to_s.strip
category = params[:category].to_s.strip
return head :unprocessable_entity if name.blank? || category.blank?

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
end

alias_names = params[:aliases].to_s.split.uniq
parent_names = params[:parent_tags].to_s.split.uniq

ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)

old_name = tag.name

tag.update!(category:)
tag.tag_name.update!(name:)

alias_names << old_name if name != old_name
alias_names.delete(name)

update_aliases!(tag, alias_names)
update_parent_tags!(tag, parent_names)

tag.reload
record_tag_version!(tag, event_type: :update, created_by_user: current_user)
end

render json: TagRepr.base(tag.reload)
end

def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
@@ -218,8 +264,8 @@ class TagsController < ApplicationController

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
end

@@ -237,7 +283,7 @@ class TagsController < ApplicationController

private

def build_tag_children(tag)
def build_tag_children tag
material = tag.materials.first
file = nil
content_type = nil
@@ -251,11 +297,45 @@ class TagsController < ApplicationController
material: material.as_json&.merge(file:, content_type:))
end

def record_tag_version!(tag, event_type:, created_by_user:)
def record_tag_version! tag, event_type:, created_by_user:
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
end

def update_aliases! tag, alias_names
current_aliases = tag.tag_name.aliases.to_a

current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)

alias_tag_name.update!(canonical: nil)
end

alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name)
end
end

def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)

old_parent_tags = tag.parents.to_a

TagVersioning.record_tag_snapshots!((old_parent_tags + parent_tags).uniq,
created_by_user: current_user)

tag.reversed_tag_implications.destroy_all

parent_tags.each do |parent_tag|
next if parent_tag == tag

TagImplication.create!(tag:, parent_tag:)
end
end
end

+ 3
- 4
backend/app/representations/tag_repr.rb View File

@@ -8,10 +8,9 @@ module TagRepr
module_function

def base tag
tag.as_json(BASE)
tag.as_json(BASE).merge(aliases: tag.snapshot_aliases,
parents: tag.parents.map { _1.as_json(BASE) })
end

def many tags
tags.map { |t| base(t) }
end
def many(tags) = tags.map { |t| base(t) }
end

+ 4
- 1
backend/config/routes.rb View File

@@ -6,7 +6,7 @@ Rails.application.routes.draw do
delete ':child_id', action: :destroy
end

resources :tags, only: [:index, :show, :update] do
resources :tags, only: [:index, :show] do
collection do
get :autocomplete
get :'with-depth', action: :with_depth
@@ -19,6 +19,9 @@ Rails.application.routes.draw do
end

member do
put '', action: :update_all
patch '', action: :update

get :deerjikists
end
end


+ 319
- 12
backend/spec/requests/tags_spec.rb View File

@@ -1,7 +1,6 @@
require 'cgi'
require 'rails_helper'


RSpec.describe 'Tags API', type: :request do
let!(:tn) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tn, category: :general) }
@@ -197,6 +196,30 @@ RSpec.describe 'Tags API', type: :request do
expect(response_tags.size).to eq(1)
expect(response_names).to eq(['norm_a'])
end

it 'returns aliases and parent tags' do
parent_tag = Tag.create!(
tag_name: TagName.create!(name: 'index_parent_tag'),
category: :meme
)
TagImplication.create!(tag:, parent_tag:)

get '/tags', params: { name: 'spec_tag' }

expect(response).to have_http_status(:ok)

row = response_tags.find { |t| t['name'] == 'spec_tag' }

expect(row['aliases']).to include('unko')
expect(row['parents'].map { |t| t['name'] }).to include('index_parent_tag')

parent = row['parents'].find { |t| t['name'] == 'index_parent_tag' }
expect(parent).to include(
'id' => parent_tag.id,
'name' => 'index_parent_tag',
'category' => 'meme'
)
end
end

describe 'GET /tags/:id' do
@@ -220,6 +243,28 @@ RSpec.describe 'Tags API', type: :request do
expect(json).to have_key('created_at')
expect(json).to have_key('updated_at')
end

it 'returns aliases and parent tags' do
parent_tag = Tag.create!(
tag_name: TagName.create!(name: 'show_parent_tag'),
category: :character
)
TagImplication.create!(tag:, parent_tag:)

request

expect(response).to have_http_status(:ok)

expect(json['aliases']).to include('unko')
expect(json['parents'].map { |t| t['name'] }).to include('show_parent_tag')

parent = json['parents'].find { |t| t['name'] == 'show_parent_tag' }
expect(parent).to include(
'id' => parent_tag.id,
'name' => 'show_parent_tag',
'category' => 'character'
)
end
end

context 'when tag does not exist' do
@@ -359,9 +404,9 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.category).to eq('meta')
end

it '存在しない id だと RecordNotFound になる(通常は 404)' do
it '存在しない id なら 404 を返す' do
patch '/tags/999999999', params: { name: 'x' }
expect(response.status).to be_in([404, 500])
expect(response).to have_http_status(:not_found)
end

it 'nico category への変更は 422 を返す' do
@@ -401,21 +446,18 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.reload.category).to eq('general')
end

it 'creates nico tag versions when updating nico tag name' do
it 'returns 422 when updating nico tag name' do
nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)

expect {
patch "/tags/#{nico_tag.id}", params: { name: 'nico:tags_spec_renamed' }
}.to change(NicoTagVersion, :count).by(2)
patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' }
}.not_to change(NicoTagVersion, :count)

expect(response).to have_http_status(:ok)
expect(response).to have_http_status(:unprocessable_entity)

versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.name).to eq('nico:tags_spec_source')
expect(versions.second.name).to eq('nico:tags_spec_renamed')
expect(versions.second.created_by_user_id).to eq(member_user.id)
expect(nico_tag.reload.name).to eq('nico:tags_spec_source')
expect(nico_tag.category).to eq('nico')
end

it 'returns 422 when changing nico tag category to normal category' do
@@ -571,4 +613,269 @@ RSpec.describe 'Tags API', type: :request do
expect(response).to have_http_status(:not_found)
end
end

describe 'PUT /tags/:id' do
context '未ログイン' do
before { stub_current_user(nil) }

it '401 を返す' do
put "/tags/#{ tag.id }", params: {
name: 'new',
category: 'general',
aliases: '',
parent_tags: '',
}

expect(response).to have_http_status(:unauthorized)
end
end

context 'ログインしてゐるが member でない' do
before { stub_current_user(non_member_user) }

it '403 を返す' do
put "/tags/#{ tag.id }", params: {
name: 'new',
category: 'general',
aliases: '',
parent_tags: '',
}

expect(response).to have_http_status(:forbidden)
end
end

context 'member' do
before { stub_current_user(member_user) }

it '存在しない id なら 404 を返す' do
put '/tags/999999999', params: {
name: 'new',
category: 'general',
aliases: '',
parent_tags: '',
}

expect(response).to have_http_status(:not_found)
end

it 'name が空なら 422 を返す' do
put "/tags/#{ tag.id }", params: {
name: '',
category: 'general',
aliases: '',
parent_tags: '',
}

expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
end

it 'category が空なら 422 を返す' do
put "/tags/#{ tag.id }", params: {
name: 'new',
category: '',
aliases: '',
parent_tags: '',
}

expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
expect(tag.category).to eq('general')
end

it 'name, category, aliases, parent tags をまとめて更新できる' do
old_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_old_parent'),
category: :general
)
kept_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_kept_parent'),
category: :general
)
TagImplication.create!(tag:, parent_tag: old_parent)
TagImplication.create!(tag:, parent_tag: kept_parent)

put "/tags/#{ tag.id }", params: {
name: 'put_renamed_tag',
category: 'meme',
aliases: 'put_alias_a put_alias_b put_alias_a',
parent_tags: 'put_kept_parent put_new_parent',
}

expect(response).to have_http_status(:ok)

tag.reload

expect(tag.name).to eq('put_renamed_tag')
expect(tag.category).to eq('meme')

expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name)
expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name)

old_name_alias = TagName.find_by(name: 'spec_tag')
expect(old_name_alias).to be_present
expect(old_name_alias.canonical).to eq(tag.tag_name)

expect(alias_tn.reload.canonical).to be_nil

expect(tag.parents.map(&:name)).to contain_exactly(
'put_kept_parent',
'put_new_parent'
)

expect(TagImplication.where(tag:, parent_tag: old_parent)).not_to exist

expect(json['name']).to eq('put_renamed_tag')
expect(json['category']).to eq('meme')
expect(json['aliases']).to contain_exactly(
'put_alias_a',
'put_alias_b',
'spec_tag'
)
expect(json['parents'].map { |t| t['name'] }).to contain_exactly(
'put_kept_parent',
'put_new_parent'
)
end

it 'aliases に現在名を指定しても alias には残さない' do
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'spec_tag put_alias_self_test',
parent_tags: '',
}

expect(response).to have_http_status(:ok)

tag.reload

expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name)
expect(json['aliases']).to include('put_alias_self_test')
expect(json['aliases']).not_to include('spec_tag')
end

it 'parent_tags に自分自身を指定しても自己参照は作らない' do
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: 'spec_tag',
}

expect(response).to have_http_status(:ok)

expect(TagImplication.where(tag:, parent_tag: tag)).not_to exist
expect(tag.reload.parents).to eq([])
end

it 'initial and update tag versions を作成する' do
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_versioned_tag',
category: 'meta',
aliases: '',
parent_tags: '',
}
}.to change(TagVersion, :count).by(2)

expect(response).to have_http_status(:ok)

versions = tag.reload.tag_versions.order(:version_no)

expect(versions.map(&:event_type)).to eq(['create', 'update'])

expect(versions.first.name).to eq('spec_tag')
expect(versions.first.category).to eq('general')
expect(versions.first.aliases.split).to include('unko')

expect(versions.second.name).to eq('put_versioned_tag')
expect(versions.second.category).to eq('meta')
expect(versions.second.aliases.split).to include('spec_tag')
expect(versions.second.created_by_user_id).to eq(member_user.id)
end

it 'parent tag の snapshot も作成する' do
old_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_snapshot_old_parent'),
category: :general
)
new_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_snapshot_new_parent'),
category: :general
)
TagImplication.create!(tag:, parent_tag: old_parent)

put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: new_parent.name,
}

expect(response).to have_http_status(:ok)

expect(old_parent.reload.tag_versions.map(&:event_type)).to include('create')
expect(new_parent.reload.tag_versions.map(&:event_type)).to include('create')
end

it 'normal tag を nico category には変更できない' do
expect {
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'nico',
aliases: '',
parent_tags: '',
}
}.not_to change(TagVersion, :count)

expect(response).to have_http_status(:unprocessable_entity)

expect(tag.reload.name).to eq('spec_tag')
expect(tag.category).to eq('general')
end

it 'nico tag は更新できない' do
nico_tag = Tag.create!(
tag_name: TagName.create!(name: 'nico:put_update_all_ng'),
category: :nico
)

expect {
put "/tags/#{ nico_tag.id }", params: {
name: 'nico:put_update_all_renamed',
category: 'nico',
aliases: '',
parent_tags: '',
}
}.not_to change(NicoTagVersion, :count)

expect(response).to have_http_status(:unprocessable_entity)

expect(nico_tag.reload.name).to eq('nico:put_update_all_ng')
expect(nico_tag.category).to eq('nico')
end

it 'system tag の name は変更できない' do
system_tag = Tag.tagme
old_name = system_tag.name
old_category = system_tag.category

expect {
put "/tags/#{ system_tag.id }", params: {
name: 'put_system_tag_renamed',
category: old_category,
aliases: '',
parent_tags: '',
}
}.not_to change(TagVersion, :count)

expect(response).to have_http_status(:unprocessable_entity)

expect(system_tag.reload.name).to eq(old_name)
expect(system_tag.category).to eq(old_category)
end
end
end
end

+ 2
- 0
frontend/src/App.tsx View File

@@ -26,6 +26,7 @@ import PostNewPage from '@/pages/posts/PostNewPage'
import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage'
import TagDetailPage from '@/pages/tags/TagDetailPage'
import TagListPage from '@/pages/tags/TagListPage'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
@@ -55,6 +56,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<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 { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import { cn } from '@/lib/utils'
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 wikiTitle = pathName.split ('/')[2] ?? ''

const tagFlg = /^\/tags\/\d+/.test (pathName)

return [
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
@@ -38,10 +40,13 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'マスタ', to: '/tags' },
{ name: '別名タグ', to: '/tags/aliases', visible: false },
{ name: '上位タグ', to: '/tags/implications', visible: false },
{ 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' },
{ name: '検索', to: '/materials/search', visible: false },
@@ -114,12 +119,14 @@ export default (({ user }: Props) => {
queryKey: wikiKeys.show (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 ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) })
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })


const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
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 mWiki = match<{ title: string }> ('/wiki/:title')
const mTag = match<{ id: string }> ('/tags/:id')


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 }[] = [
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
run: prefetchPostsIndex },
@@ -180,7 +194,9 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
&& Boolean (mWiki (u.pathname))),
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> => {


+ 158
- 0
frontend/src/pages/tags/TagDetailPage.tsx View File

@@ -0,0 +1,158 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'

import TagLink from '@/components/TagLink'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiPut } from '@/lib/api'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { cn } from '@/lib/utils'

import type { FC, FormEvent } from 'react'

import type { Category, Tag } from '@/types'


export default (() => {
const { id } = useParams ()
const tagId = String (id ?? '')
const tagKey = tagsKeys.show (tagId)

const { data: tag, isLoading: loading } = useQuery ({
queryKey: tagKey,
queryFn: () => fetchTag (tagId) })

const [name, setName] = useState ('')
const [category, setCategory] = useState<Category> ('general')
const [aliases, setAliases] = useState ('')
const [parentTags, setParentTags] = useState ('')
const [disabled, setDisabled] = useState (true)

const qc = useQueryClient ()

const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()

const formData = new FormData
formData.append ('name', name)
formData.append ('category', category)
formData.append ('aliases', aliases)
formData.append ('parent_tags', parentTags)

try
{
const data = await apiPut<Tag> (`/tags/${ id }`, formData)

setName (data.name)
setCategory (data.category as Category)
setAliases (data.aliases.join (' '))
setParentTags (data.parents.map (t => t.name).join (' '))

qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '更新しました.' })
}
catch
{
toast ({ description: '更新に失敗しました.' })
}
}

useEffect (() => {
if (!(tag))
{
setDisabled (true)
return
}

setName (tag.name)
setCategory (tag.category as Category)
setAliases (tag.aliases.join (' '))
setParentTags (tag.parents.map (t => t.name).join (' '))
setDisabled (tag.category === 'nico')
}, [tag])

return (
<MainArea>
{(loading || !(tag)) ? 'Loading...' : (
<div className="max-w-xl">
<PageTitle>
<TagLink
tag={tag}
withWiki={false}
withCount={false}/>
</PageTitle>

<form onSubmit={handleSubmit} className="my-4 space-y-2">
{/* 名称 */}
<div>
<Label>名称</Label>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={name}
onChange={e => setName (e.target.value)}
className="w-full border p-2 rounded"/>
</div>

{/* カテゴリ */}
<div>
<Label>カテゴリ</Label>
<select
disabled={disabled}
value={category ?? ''}
onChange={e => setCategory(e.target.value as Category)}
className="w-full border p-2 rounded">
{CATEGORIES.filter (cat => tag.category === 'nico' || cat !== 'nico')
.map (cat => (
<option key={cat} value={cat}>
{CATEGORY_NAMES[cat]}
</option>))}
</select>
</div>

{/* 別名 */}
<div>
<Label>別名</Label>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={aliases}
onChange={e => setAliases (e.target.value)}
className="w-full border p-2 rounded"/>
</div>

{/* 上位タグ */}
<div>
<Label>上位タグ</Label>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={parentTags}
onChange={e => setParentTags (e.target.value)}
className="w-full border p-2 rounded"/>
</div>

<div className="py-3">
<button
type="submit"
disabled={disabled}
className={cn ('px-4 py-2 rounded',
(disabled
? 'text-gray-300 bg-gray-500'
: 'text-white bg-blue-500'))}>
更新
</button>
</div>
</form>
</div>)}
</MainArea>)
}) satisfies FC

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

@@ -205,13 +205,15 @@ export default (() => {
{loading ? 'Loading...' : (results.length > 0 ? (
<div className="mt-4">
<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>
<col className="w-72"/>
<col className="w-48"/>
<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"/>
</colgroup>

@@ -226,18 +228,20 @@ export default (() => {
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="category"
label="カテゴリ"
by="post_count"
label="件数"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="post_count"
label="件数"
by="category"
label="カテゴリ"
currentOrder={order}
defaultDirection={defaultDirection}/>
</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">
<SortHeader<FetchTagsOrderField>
by="created_at"
@@ -260,10 +264,23 @@ export default (() => {
{results.map (row => (
<tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<TagLink tag={row} withCount={false}/>
<TagLink
tag={row}
to={`/tags/${ encodeURIComponent (row.id) }`}
withCount={false}/>
</td>
<td className="p-2">{CATEGORY_NAMES[row.category]}</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.updatedAt)}</td>
<td className="p-2">


+ 9
- 7
frontend/src/pages/wiki/WikiDetailPage.tsx View File

@@ -114,13 +114,15 @@ export default () => {
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
</h1>
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
</article>

{(!(version) && posts.length > 0) && (
<TabGroup>
<Tab name="広場">
<PostList posts={posts}/>
</Tab>
</TabGroup>)}
{(!(version) && posts.length > 0) && (
<div className="not-prose">
<TabGroup>
<Tab name="広場">
<PostList posts={posts}/>
</Tab>
</TabGroup>
</div>)}
</article>
</MainArea>)
}

+ 2
- 0
frontend/src/types.ts View File

@@ -165,6 +165,8 @@ export type Tag = {
id: number
name: string
category: Category
aliases: string[]
parents: Tag[]
postCount: number
createdAt: string
updatedAt: string


Loading…
Cancel
Save