From 43cd38a216cb4a632998b8337dcba3fcf02f459c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Thu, 23 Apr 2026 00:06:49 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=BF=E3=82=B0=E8=A9=B3=E7=B4=B0=20(#318)?= =?UTF-8?q?=20(#328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #318 #318 #318 #318 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/328 --- backend/app/controllers/tags_controller.rb | 90 +++++- backend/app/representations/tag_repr.rb | 7 +- backend/config/routes.rb | 5 +- backend/spec/requests/tags_spec.rb | 331 ++++++++++++++++++++- frontend/src/App.tsx | 2 + frontend/src/components/TopNav.tsx | 19 +- frontend/src/lib/prefetchers.ts | 18 +- frontend/src/pages/tags/TagDetailPage.tsx | 158 ++++++++++ frontend/src/pages/tags/TagListPage.tsx | 37 ++- frontend/src/pages/wiki/WikiDetailPage.tsx | 16 +- frontend/src/types.ts | 2 + 11 files changed, 639 insertions(+), 46 deletions(-) create mode 100644 frontend/src/pages/tags/TagDetailPage.tsx diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 94a7041..3785a9e 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -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 diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index df6925b..ecbed17 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index edd978f..373bb17 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -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 diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 42d3728..d3bc594 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7316e8b..f8178f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }: { }/> }/> }/> + }/> }/> }/> }> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 2b5eee2..c06f1b9 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -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: , 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) diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index ad18e1b..166daa8 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -14,6 +14,7 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise 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 => { diff --git a/frontend/src/pages/tags/TagDetailPage.tsx b/frontend/src/pages/tags/TagDetailPage.tsx new file mode 100644 index 0000000..6452508 --- /dev/null +++ b/frontend/src/pages/tags/TagDetailPage.tsx @@ -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 ('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 (`/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 ( + + {(loading || !(tag)) ? 'Loading...' : ( +
+ + + + +
+ {/* 名称 */} +
+ + {/* TODO: 補完に対応させる */} + setName (e.target.value)} + className="w-full border p-2 rounded"/> +
+ + {/* カテゴリ */} +
+ + +
+ + {/* 別名 */} +
+ + {/* TODO: 補完に対応させる */} + setAliases (e.target.value)} + className="w-full border p-2 rounded"/> +
+ + {/* 上位タグ */} +
+ + {/* TODO: 補完に対応させる */} + setParentTags (e.target.value)} + className="w-full border p-2 rounded"/> +
+ +
+ +
+
+
)} +
) +}) satisfies FC diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index e1ce2fc..f183aa7 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -205,13 +205,15 @@ export default (() => { {loading ? 'Loading...' : (results.length > 0 ? (
- +
- - - + + + + + @@ -226,18 +228,20 @@ export default (() => { + + - + + +
- by="category" - label="カテゴリ" + by="post_count" + label="件数" currentOrder={order} defaultDirection={defaultDirection}/> - by="post_count" - label="件数" + by="category" + label="カテゴリ" currentOrder={order} defaultDirection={defaultDirection}/> 別名上位タグ by="created_at" @@ -260,10 +264,23 @@ export default (() => { {results.map (row => (
- + {CATEGORY_NAMES[row.category]} {row.postCount}{CATEGORY_NAMES[row.category]}{row.aliases.join (' ')} + {row.parents.map (t => ( + + + ))} + {dateString (row.createdAt)} {dateString (row.updatedAt)} diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index 7eab461..4ead153 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.tsx @@ -114,13 +114,15 @@ export default () => { {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> {loading ?
Loading...
: } - - {(!(version) && posts.length > 0) && ( - - - - - )} + {(!(version) && posts.length > 0) && ( +
+ + + + + +
)} + ) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 12f8838..8adb5ff 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -165,6 +165,8 @@ export type Tag = { id: number name: string category: Category + aliases: string[] + parents: Tag[] postCount: number createdAt: string updatedAt: string