Browse Source

#321

feature/321
みてるぞ 1 week ago
parent
commit
2ad25b3950
9 changed files with 347 additions and 7 deletions
  1. +87
    -0
      backend/app/controllers/tag_versions_controller.rb
  2. +1
    -0
      backend/config/routes.rb
  3. +2
    -0
      frontend/src/App.tsx
  4. +1
    -0
      frontend/src/components/TopNav.tsx
  5. +16
    -3
      frontend/src/lib/prefetchers.ts
  6. +5
    -3
      frontend/src/lib/queryKeys.ts
  7. +12
    -1
      frontend/src/lib/tags.ts
  8. +212
    -0
      frontend/src/pages/tags/TagHistoryPage.tsx
  9. +11
    -0
      frontend/src/types.ts

+ 87
- 0
backend/app/controllers/tag_versions_controller.rb View File

@@ -0,0 +1,87 @@
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_tags =
TagRepr.many(
Tag
.includes(:tag_name, :materials, { tag_name: :wiki_page })
.where(id: split_parent_tag_ids(row.parent_tag_ids))
.to_a)
prev_parent_tags =
TagRepr.many(
Tag
.includes(:tag_name, :materials, { tag_name: :wiki_page })
.where(id: split_parent_tag_ids(row.attributes['prev_parent_tag_ids']))
.to_a)

{ 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: build_version_values(cur_parent_tags, prev_parent_tags, key: :tag),
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

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

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


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

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


+ 1
- 0
frontend/src/components/TopNav.tsx View File

@@ -41,6 +41,7 @@ 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 })`,


+ 16
- 3
frontend/src/lib/prefetchers.ts View File

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


+ 5
- 3
frontend/src/lib/queryKeys.ts View File

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


+ 12
- 1
frontend/src/lib/tags.ts View File

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

+ 212
- 0
frontend/src/pages/tags/TagHistoryPage.tsx View File

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

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

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


Loading…
Cancel
Save