#321 #321 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/330feature/200
| @@ -0,0 +1,92 @@ | |||||
| class TagVersionsController < ApplicationController | |||||
| def index | |||||
| tag_id = params[:id].presence | |||||
| page = (params[:page].presence || 1).to_i | |||||
| limit = (params[:limit].presence || 20).to_i | |||||
| page = 1 if page < 1 | |||||
| limit = 1 if limit < 1 | |||||
| offset = (page - 1) * limit | |||||
| q = TagVersion.joins(<<~SQL.squish) | |||||
| LEFT JOIN | |||||
| tag_versions prev | |||||
| ON | |||||
| prev.tag_id = tag_versions.tag_id | |||||
| AND prev.version_no = tag_versions.version_no - 1 | |||||
| SQL | |||||
| .select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category', | |||||
| 'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids') | |||||
| q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id | |||||
| count = q.except(:select, :order, :limit, :offset).count | |||||
| versions = q.order(Arel.sql('tag_versions.created_at DESC, tag_versions.id DESC')) | |||||
| .limit(limit) | |||||
| .offset(offset) | |||||
| render json: { versions: serialise_versions(versions), count: } | |||||
| end | |||||
| private | |||||
| def serialise_versions rows | |||||
| user_ids = rows.map(&:created_by_user_id).compact.uniq | |||||
| users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h | |||||
| rows.map do |row| | |||||
| cur_aliases = split_values(row.aliases) | |||||
| prev_aliases = split_values(row.attributes['prev_aliases']) | |||||
| cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids) | |||||
| prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids']) | |||||
| all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids) | |||||
| tags_by_id = | |||||
| Tag | |||||
| .includes(:tag_name, :materials, { tag_name: :wiki_page }) | |||||
| .where(id: all_parent_tag_ids) | |||||
| .index_by(&:id) | |||||
| parent_tags = | |||||
| build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id) | |||||
| .map do |h| | |||||
| { tag: TagRepr.base(tags_by_id[h[:tag_id]]), | |||||
| type: h[:type] } | |||||
| end | |||||
| { tag_id: row.tag_id, | |||||
| version_no: row.version_no, | |||||
| event_type: row.event_type, | |||||
| name: { current: row.name, prev: row.attributes['prev_name'] }, | |||||
| category: { current: row.category, prev: row.attributes['prev_category'] }, | |||||
| aliases: build_version_values(cur_aliases, prev_aliases, key: :name), | |||||
| parent_tags:, | |||||
| created_at: row.created_at.iso8601, | |||||
| created_by_user: row.created_by_user_id && | |||||
| { id: row.created_by_user_id, | |||||
| name: users_by_id[row.created_by_user_id] } } | |||||
| end | |||||
| end | |||||
| def build_version_values cur_values, prev_values, key: | |||||
| (cur_values | prev_values).map do |value| | |||||
| type = | |||||
| if cur_values.include?(value) && prev_values.include?(value) | |||||
| 'context' | |||||
| elsif cur_values.include?(value) | |||||
| 'added' | |||||
| else | |||||
| 'removed' | |||||
| end | |||||
| { key => value, type: } | |||||
| end | |||||
| end | |||||
| def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?) | |||||
| def split_parent_tag_ids(values) = split_values(values).map(&:to_i) | |||||
| end | |||||
| @@ -10,6 +10,7 @@ Rails.application.routes.draw do | |||||
| collection do | collection do | ||||
| get :autocomplete | get :autocomplete | ||||
| get :'with-depth', action: :with_depth | get :'with-depth', action: :with_depth | ||||
| get :versions, to: 'tag_versions#index' | |||||
| scope :name do | scope :name do | ||||
| get ':name/deerjikists', action: :deerjikists_by_name | get ':name/deerjikists', action: :deerjikists_by_name | ||||
| @@ -0,0 +1,218 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe 'TagVersions API', type: :request do | |||||
| let(:member) { create(:user, :member, name: 'version member') } | |||||
| let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) } | |||||
| let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) } | |||||
| let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) } | |||||
| let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) } | |||||
| let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) } | |||||
| let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) } | |||||
| let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) } | |||||
| let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) } | |||||
| let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) } | |||||
| def create_tag_version!( | |||||
| tag:, | |||||
| version_no:, | |||||
| event_type:, | |||||
| name:, | |||||
| category:, | |||||
| aliases: [], | |||||
| parent_tags: [], | |||||
| created_by_user:, | |||||
| created_at: | |||||
| ) | |||||
| TagVersion.create!( | |||||
| tag: tag, | |||||
| version_no: version_no, | |||||
| event_type: event_type, | |||||
| name: name, | |||||
| category: category, | |||||
| aliases: Array(aliases).join(' '), | |||||
| parent_tag_ids: Array(parent_tags).map(&:id).join(' '), | |||||
| created_by_user: created_by_user, | |||||
| created_at: created_at | |||||
| ) | |||||
| end | |||||
| let!(:v1) do | |||||
| create_tag_version!( | |||||
| tag: tag, | |||||
| version_no: 1, | |||||
| event_type: 'create', | |||||
| name: 'old_tag_name', | |||||
| category: 'general', | |||||
| aliases: ['alias_shared', 'alias_old'], | |||||
| parent_tags: [parent_shared, parent_old], | |||||
| created_by_user: member, | |||||
| created_at: t_v1 | |||||
| ) | |||||
| end | |||||
| let!(:v2) do | |||||
| create_tag_version!( | |||||
| tag: tag, | |||||
| version_no: 2, | |||||
| event_type: 'update', | |||||
| name: 'new_tag_name', | |||||
| category: 'meme', | |||||
| aliases: ['alias_shared', 'alias_new'], | |||||
| parent_tags: [parent_shared, parent_new], | |||||
| created_by_user: member, | |||||
| created_at: t_v2 | |||||
| ) | |||||
| end | |||||
| let!(:other_v1) do | |||||
| create_tag_version!( | |||||
| tag: other_tag, | |||||
| version_no: 1, | |||||
| event_type: 'create', | |||||
| name: 'other_tag_name', | |||||
| category: 'general', | |||||
| aliases: ['other_alias'], | |||||
| parent_tags: [other_parent], | |||||
| created_by_user: member, | |||||
| created_at: t_other | |||||
| ) | |||||
| end | |||||
| describe 'GET /tags/versions' do | |||||
| it 'returns all versions in reverse chronological order when id is omitted' do | |||||
| get '/tags/versions' | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json).to include('versions', 'count') | |||||
| expect(json.fetch('count')).to eq(3) | |||||
| versions = json.fetch('versions') | |||||
| expect(versions.map { |v| [v['tag_id'], v['version_no']] }).to eq([ | |||||
| [other_tag.id, 1], | |||||
| [tag.id, 2], | |||||
| [tag.id, 1] | |||||
| ]) | |||||
| end | |||||
| it 'returns versions for the specified tag with diffs' do | |||||
| get '/tags/versions', params: { id: tag.id } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json).to include('versions', 'count') | |||||
| expect(json.fetch('count')).to eq(2) | |||||
| versions = json.fetch('versions') | |||||
| expect(versions.map { |v| v['tag_id'] }.uniq).to eq([tag.id]) | |||||
| expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) | |||||
| latest = versions.first | |||||
| expect(latest).to include( | |||||
| 'tag_id' => tag.id, | |||||
| 'version_no' => 2, | |||||
| 'event_type' => 'update', | |||||
| 'created_by_user' => { | |||||
| 'id' => member.id, | |||||
| 'name' => member.name | |||||
| } | |||||
| ) | |||||
| expect(latest.fetch('name')).to eq( | |||||
| 'current' => 'new_tag_name', | |||||
| 'prev' => 'old_tag_name' | |||||
| ) | |||||
| expect(latest.fetch('category')).to eq( | |||||
| 'current' => 'meme', | |||||
| 'prev' => 'general' | |||||
| ) | |||||
| expect(latest.fetch('aliases')).to include( | |||||
| { 'name' => 'alias_shared', 'type' => 'context' }, | |||||
| { 'name' => 'alias_new', 'type' => 'added' }, | |||||
| { 'name' => 'alias_old', 'type' => 'removed' } | |||||
| ) | |||||
| expect(latest.fetch('parent_tags')).to include( | |||||
| a_hash_including( | |||||
| 'type' => 'context', | |||||
| 'tag' => a_hash_including( | |||||
| 'id' => parent_shared.id | |||||
| ) | |||||
| ), | |||||
| a_hash_including( | |||||
| 'type' => 'added', | |||||
| 'tag' => a_hash_including( | |||||
| 'id' => parent_new.id | |||||
| ) | |||||
| ), | |||||
| a_hash_including( | |||||
| 'type' => 'removed', | |||||
| 'tag' => a_hash_including( | |||||
| 'id' => parent_old.id | |||||
| ) | |||||
| ) | |||||
| ) | |||||
| expect(latest.fetch('created_at')).to eq(t_v2.iso8601) | |||||
| first = versions.second | |||||
| expect(first).to include( | |||||
| 'tag_id' => tag.id, | |||||
| 'version_no' => 1, | |||||
| 'event_type' => 'create', | |||||
| 'created_by_user' => { | |||||
| 'id' => member.id, | |||||
| 'name' => member.name | |||||
| } | |||||
| ) | |||||
| expect(first.fetch('name')).to eq( | |||||
| 'current' => 'old_tag_name', | |||||
| 'prev' => nil | |||||
| ) | |||||
| expect(first.fetch('category')).to eq( | |||||
| 'current' => 'general', | |||||
| 'prev' => nil | |||||
| ) | |||||
| expect(first.fetch('aliases')).to include( | |||||
| { 'name' => 'alias_shared', 'type' => 'added' }, | |||||
| { 'name' => 'alias_old', 'type' => 'added' } | |||||
| ) | |||||
| expect(first.fetch('parent_tags')).to include( | |||||
| a_hash_including( | |||||
| 'type' => 'added', | |||||
| 'tag' => a_hash_including( | |||||
| 'id' => parent_shared.id | |||||
| ) | |||||
| ), | |||||
| a_hash_including( | |||||
| 'type' => 'added', | |||||
| 'tag' => a_hash_including( | |||||
| 'id' => parent_old.id | |||||
| ) | |||||
| ) | |||||
| ) | |||||
| expect(first.fetch('created_at')).to eq(t_v1.iso8601) | |||||
| end | |||||
| it 'returns empty when the specified tag has no versions' do | |||||
| fresh_tag = create(:tag, name: 'no_versions_tag', category: :general) | |||||
| get '/tags/versions', params: { id: fresh_tag.id } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json.fetch('versions')).to eq([]) | |||||
| expect(json.fetch('count')).to eq(0) | |||||
| end | |||||
| it 'clamps page and limit to at least 1' do | |||||
| get '/tags/versions', params: { id: tag.id, page: 0, limit: 0 } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json.fetch('count')).to eq(2) | |||||
| versions = json.fetch('versions') | |||||
| expect(versions.size).to eq(1) | |||||
| expect(versions.first['version_no']).to eq(2) | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -27,6 +27,7 @@ import PostSearchPage from '@/pages/posts/PostSearchPage' | |||||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | import ServiceUnavailable from '@/pages/ServiceUnavailable' | ||||
| import SettingPage from '@/pages/users/SettingPage' | import SettingPage from '@/pages/users/SettingPage' | ||||
| import TagDetailPage from '@/pages/tags/TagDetailPage' | import TagDetailPage from '@/pages/tags/TagDetailPage' | ||||
| import TagHistoryPage from '@/pages/tags/TagHistoryPage' | |||||
| import TagListPage from '@/pages/tags/TagListPage' | import TagListPage from '@/pages/tags/TagListPage' | ||||
| import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' | import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' | ||||
| import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | ||||
| @@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||||
| <Route path="/tags" element={<TagListPage/>}/> | <Route path="/tags" element={<TagListPage/>}/> | ||||
| <Route path="/tags/:id" 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="/tags/changes" element={<TagHistoryPage/>}/> | |||||
| <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | ||||
| <Route path="/materials" element={<MaterialBasePage/>}> | <Route path="/materials" element={<MaterialBasePage/>}> | ||||
| <Route index element={<MaterialListPage/>}/> | <Route index element={<MaterialListPage/>}/> | ||||
| @@ -41,12 +41,14 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| { name: 'タグ', to: '/tags', subMenu: [ | { name: 'タグ', to: '/tags', subMenu: [ | ||||
| { name: 'マスタ', to: '/tags' }, | { name: 'マスタ', to: '/tags' }, | ||||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | { name: 'ニコニコ連携', to: '/tags/nico' }, | ||||
| { name: '履歴', to: '/tags/changes' }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, | ||||
| { component: <Separator/>, visible: tagFlg }, | { component: <Separator/>, visible: tagFlg }, | ||||
| { name: `広場 (${ postCount || 0 })`, | { name: `広場 (${ postCount || 0 })`, | ||||
| to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, | to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, | ||||
| visible: tagFlg }, | visible: tagFlg }, | ||||
| { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, visible: false }] }, | |||||
| { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, | |||||
| visible: tagFlg && tag?.category !== 'nico' }] }, | |||||
| { 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 }, | ||||
| @@ -3,7 +3,7 @@ import { match } from 'path-to-regexp' | |||||
| import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' | import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' | ||||
| import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' | import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' | ||||
| import { fetchTagByName, fetchTag, fetchTags } from '@/lib/tags' | |||||
| import { fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' | |||||
| import { fetchWikiPage, | import { fetchWikiPage, | ||||
| fetchWikiPageByTitle, | fetchWikiPageByTitle, | ||||
| fetchWikiPages } from '@/lib/wiki' | fetchWikiPages } from '@/lib/wiki' | ||||
| @@ -183,6 +183,17 @@ const prefetchTagShow: Prefetcher = async (qc, url) => { | |||||
| } | } | ||||
| const prefetchTagChanges: Prefetcher = async (qc, url) => { | |||||
| const id = url.searchParams.get ('id') | |||||
| const page = Number (url.searchParams.get ('page') || 1) | |||||
| const limit = Number (url.searchParams.get ('limit') || 20) | |||||
| await qc.prefetchQuery ({ | |||||
| queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), | |||||
| queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) | |||||
| } | |||||
| 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 }, | ||||
| @@ -195,8 +206,10 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] | |||||
| && 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 }] | |||||
| { test: u => (!(['/tags/nico', '/tags/changes'].includes (u.pathname)) | |||||
| && Boolean (mTag (u.pathname))), | |||||
| run: prefetchTagShow }, | |||||
| { test: u => u.pathname === '/tags/changes', run: prefetchTagChanges }] | |||||
| export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | ||||
| @@ -9,9 +9,11 @@ export const postsKeys = { | |||||
| ['posts', 'changes', p] as const } | ['posts', 'changes', p] as const } | ||||
| export const tagsKeys = { | export const tagsKeys = { | ||||
| root: ['tags'] as const, | |||||
| index: (p: FetchTagsParams) => ['tags', 'index', p] as const, | |||||
| show: (name: string) => ['tags', name] as const } | |||||
| root: ['tags'] as const, | |||||
| index: (p: FetchTagsParams) => ['tags', 'index', p] as const, | |||||
| show: (name: string) => ['tags', name] as const, | |||||
| changes: (p: { id?: string; page: number; limit: number }) => | |||||
| ['tags', 'changes', p] as const } | |||||
| export const wikiKeys = { | export const wikiKeys = { | ||||
| root: ['wiki'] as const, | root: ['wiki'] as const, | ||||
| @@ -1,6 +1,6 @@ | |||||
| import { apiGet } from '@/lib/api' | import { apiGet } from '@/lib/api' | ||||
| import type { FetchTagsParams, Tag } from '@/types' | |||||
| import type { FetchTagsParams, Tag, TagVersion } from '@/types' | |||||
| export const fetchTags = async ( | export const fetchTags = async ( | ||||
| @@ -45,3 +45,14 @@ export const fetchTagByName = async (name: string): Promise<Tag | null> => { | |||||
| return null | return null | ||||
| } | } | ||||
| } | } | ||||
| export const fetchTagChanges = async ( | |||||
| { id, page, limit }: { | |||||
| id?: string | |||||
| page: number | |||||
| limit: number }, | |||||
| ): Promise<{ | |||||
| versions: TagVersion[] | |||||
| count: number }> => | |||||
| await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) | |||||
| @@ -0,0 +1,212 @@ | |||||
| import { useQuery, useQueryClient } from '@tanstack/react-query' | |||||
| import { useEffect } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | |||||
| import { useLocation } from 'react-router-dom' | |||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import PageTitle from '@/components/common/PageTitle' | |||||
| import Pagination from '@/components/common/Pagination' | |||||
| import MainArea from '@/components/layout/MainArea' | |||||
| import { toast } from '@/components/ui/use-toast' | |||||
| import { SITE_TITLE } from '@/config' | |||||
| import { CATEGORY_NAMES } from '@/consts' | |||||
| import { apiPut } from '@/lib/api' | |||||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||||
| import { fetchTagChanges } from '@/lib/tags' | |||||
| import { cn, dateString } from '@/lib/utils' | |||||
| import type { FC } from 'react' | |||||
| const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | |||||
| <> | |||||
| {(diff.prev && diff.prev !== diff.current) && ( | |||||
| <> | |||||
| <del className="text-red-600 dark:text-red-400"> | |||||
| {diff.prev} | |||||
| </del> | |||||
| {diff.current && <br/>} | |||||
| </>)} | |||||
| {diff.current} | |||||
| </>) | |||||
| export default (() => { | |||||
| const location = useLocation () | |||||
| const query = new URLSearchParams (location.search) | |||||
| const id = query.get ('id') | |||||
| const page = Number (query.get ('page') ?? 1) | |||||
| const limit = Number (query.get ('limit') ?? 20) | |||||
| const { data, isLoading: loading } = useQuery ({ | |||||
| queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), | |||||
| queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) | |||||
| const changes = data?.versions ?? [] | |||||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | |||||
| const qc = useQueryClient () | |||||
| useEffect (() => { | |||||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | |||||
| }, [location.search]) | |||||
| return ( | |||||
| <MainArea> | |||||
| <Helmet> | |||||
| <title>{`タグ定義変更履歴 | ${ SITE_TITLE }`}</title> | |||||
| </Helmet> | |||||
| <PageTitle> | |||||
| タグ定義変更履歴 | |||||
| {id && <>: タグ {<PrefetchLink to={`/tags/${ id }`}>#{id}</PrefetchLink>}</>} | |||||
| </PageTitle> | |||||
| {loading ? 'Loading...' : ( | |||||
| <> | |||||
| <div className="overflow-x-auto"> | |||||
| <table className="w-full min-w-[1200px] table-fixed border-collapse"> | |||||
| <colgroup> | |||||
| {/* 版 */} | |||||
| <col className="w-40"/> | |||||
| {/* 名称 */} | |||||
| <col className="w-96"/> | |||||
| {/* カテゴリ */} | |||||
| <col className="w-96"/> | |||||
| {/* 別名 */} | |||||
| <col className="w-[48rem]"/> | |||||
| {/* 上位タグ */} | |||||
| <col className="w-96"/> | |||||
| {/* 更新日時 */} | |||||
| <col className="w-64"/> | |||||
| {/* (差戻ボタン) */} | |||||
| <col className="w-20"/> | |||||
| </colgroup> | |||||
| <thead className="border-b-2 border-black dark:border-white"> | |||||
| <tr> | |||||
| <th className="p-2 text-left">版</th> | |||||
| <th className="p-2 text-left">名称</th> | |||||
| <th className="p-2 text-left">カテゴリ</th> | |||||
| <th className="p-2 text-left">別名</th> | |||||
| <th className="p-2 text-left">上位タグ</th> | |||||
| <th className="p-2 text-left">更新日時</th> | |||||
| <th className="p-2"/> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| {changes.map (change => ( | |||||
| <tr key={`${ change.tagId }.${ change.versionNo }`} | |||||
| className={cn ('even:bg-gray-100 dark:even:bg-gray-700')}> | |||||
| <td className="p-2">{change.tagId}.{change.versionNo}</td> | |||||
| <td className="p-2 break-all">{renderDiff (change.name)}</td> | |||||
| <td className="p-2 break-all"> | |||||
| {renderDiff ({ | |||||
| current: CATEGORY_NAMES[change.category.current], | |||||
| prev: (change.category.prev | |||||
| && CATEGORY_NAMES[change.category.prev]) })} | |||||
| </td> | |||||
| <td className="p-2"> | |||||
| {change.aliases.map ((tag, i) => ( | |||||
| tag.type === 'added' | |||||
| ? ( | |||||
| <ins | |||||
| key={i} | |||||
| className="mr-2 text-green-600 dark:text-green-400"> | |||||
| {tag.name} | |||||
| </ins>) | |||||
| : ( | |||||
| tag.type === 'removed' | |||||
| ? ( | |||||
| <del | |||||
| key={i} | |||||
| className="mr-2 text-red-600 dark:text-red-400"> | |||||
| {tag.name} | |||||
| </del>) | |||||
| : ( | |||||
| <span key={i} className="mr-2"> | |||||
| {tag.name} | |||||
| </span>))))} | |||||
| </td> | |||||
| <td className="p-2"> | |||||
| {change.parentTags.map ((tag, i) => ( | |||||
| tag.type === 'added' | |||||
| ? ( | |||||
| <ins | |||||
| key={i} | |||||
| className="mr-2 text-green-600 dark:text-green-400"> | |||||
| {tag.tag.name} | |||||
| </ins>) | |||||
| : ( | |||||
| tag.type === 'removed' | |||||
| ? ( | |||||
| <del | |||||
| key={i} | |||||
| className="mr-2 text-red-600 dark:text-red-400"> | |||||
| {tag.tag.name} | |||||
| </del>) | |||||
| : ( | |||||
| <span key={i} className="mr-2"> | |||||
| {tag.tag.name} | |||||
| </span>))))} | |||||
| </td> | |||||
| <td className="p-2"> | |||||
| {change.createdByUser | |||||
| ? ( | |||||
| <PrefetchLink to={`/users/${ change.createdByUser.id }`}> | |||||
| {change.createdByUser.name | |||||
| || `名もなきニジラー(#${ change.createdByUser.id })`} | |||||
| </PrefetchLink>) | |||||
| : 'bot 操作'} | |||||
| <br/> | |||||
| {dateString (change.createdAt)} | |||||
| </td> | |||||
| <td className="p-2"> | |||||
| <a | |||||
| href="#" | |||||
| onClick={async e => { | |||||
| e.preventDefault () | |||||
| if (!(confirm ( | |||||
| `タグ『${ change.name.current }』を版 ${ | |||||
| change.versionNo } に差戻します.\nよろしいですか?`))) | |||||
| return | |||||
| try | |||||
| { | |||||
| await apiPut ( | |||||
| `/tags/${ change.tagId }`, | |||||
| { name: change.name.current, | |||||
| category: change.category.current, | |||||
| aliases: | |||||
| change.aliases | |||||
| .filter (t => t.type !== 'removed') | |||||
| .map (t => t.name) | |||||
| .join (' '), | |||||
| parent_tags: | |||||
| change.parentTags | |||||
| .filter (t => t.type !== 'removed') | |||||
| .map (t => t.tag.name) | |||||
| .join (' ') }) | |||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||||
| toast ({ description: '差戻しました.' }) | |||||
| } | |||||
| catch | |||||
| { | |||||
| toast ({ description: '差戻に失敗……' }) | |||||
| } | |||||
| }}> | |||||
| 復元 | |||||
| </a> | |||||
| </td> | |||||
| </tr>))} | |||||
| </tbody> | |||||
| </table> | |||||
| </div> | |||||
| <Pagination page={page} totalPages={totalPages}/> | |||||
| </>)} | |||||
| </MainArea>) | |||||
| }) satisfies FC | |||||
| @@ -175,6 +175,17 @@ export type Tag = { | |||||
| children?: Tag[] | children?: Tag[] | ||||
| matchedAlias?: string | null } | matchedAlias?: string | null } | ||||
| export type TagVersion = { | |||||
| tagId: number | |||||
| versionNo: number | |||||
| eventType: 'create' | 'update' | 'discard' | 'restore' | |||||
| name: { current: string; prev: string | null } | |||||
| category: { current: Category; prev: Category | null } | |||||
| aliases: { name: string; type: 'context' | 'added' | 'removed' }[] | |||||
| parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[] | |||||
| createdAt: string | |||||
| createdByUser: { id: number; name: string | null } | null } | |||||
| export type Theatre = { | export type Theatre = { | ||||
| id: number | id: number | ||||
| name: string | null | name: string | null | ||||