diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index 7b30b5a..bcf67c8 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -10,20 +10,108 @@ class PostVersionsController < ApplicationController offset = (page - 1) * limit - tag_name = nil - if tag_id - tag_name = TagName.joins(:tag).find_by(tag: { id: tag_id }) - return render json: [] unless tag_name + tag_name = + if tag_id + TagName.joins(:tag).find_by(tag: { id: tag_id }) + end + return render json: { versions: [], count: 0 } if tag_id && tag_name.blank? + + q = PostVersion.joins(<<~SQL.squish) + LEFT JOIN + post_versions prev + ON + prev.post_id = post_versions.post_id + AND prev.version_no = post_versions.version_no - 1 + SQL + .select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url', + 'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags', + 'prev.original_created_from AS prev_original_created_from', + 'prev.original_created_before AS prev_original_created_before') + q = q.where('post_versions.post_id = ?', post_id) if post_id + if tag_name + escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name) + q = q.where("CONCAT(' ', post_versions.tags, ' ') LIKE ?", "% #{ escaped } %") end - q = PostVersion - q = q.where(post_id:) if post_id - q = q.where("CONCAT(' ', tags, ' ') LIKE ?", "% #{ tag_name } %") if tag_name + count = q.except(:select, :order, :limit, :offset).count - versions = q.order(created_at: :desc, id: :desc) + versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC')) .limit(limit) .offset(offset) - render json: { versions:, count: q.count } + 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_tags = split_tags(row.tags) + prev_tags = split_tags(row.attributes['prev_tags']) + + { + post_id: row.post_id, + version_no: row.version_no, + event_type: row.event_type, + title: { + current: row.title, + prev: row.attributes['prev_title'] + }, + url: { + current: row.url, + prev: row.attributes['prev_url'] + }, + thumbnail: { + current: nil, + prev: nil + }, + thumbnail_base: { + current: row.thumbnail_base, + prev: row.attributes['prev_thumbnail_base'] + }, + tags: build_version_tags(cur_tags, prev_tags), + original_created_from: { + current: row.original_created_from&.iso8601, + prev: row.attributes['prev_original_created_from']&.iso8601 + }, + original_created_before: { + current: row.original_created_before&.iso8601, + prev: row.attributes['prev_original_created_before']&.iso8601 + }, + created_at: row.created_at.iso8601, + created_by_user: + if row.created_by_user_id + { + id: row.created_by_user_id, + name: users_by_id[row.created_by_user_id] + } + end + } + end + end + + def build_version_tags(cur_tags, prev_tags) + (cur_tags | prev_tags).map do |name| + type = + if cur_tags.include?(name) && prev_tags.include?(name) + 'context' + elsif cur_tags.include?(name) + 'added' + else + 'removed' + end + + { + name:, + type: + } + end + end + + def split_tags(tags) + tags.to_s.split(/\s+/).reject(&:blank?) end end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index c6e2d7d..26ca581 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -44,7 +44,7 @@ class PostsController < ApplicationController filtered_posts .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) - .preload(tags: { tag_name: :wiki_page }) + .preload(tags: [:materials, { tag_name: :wiki_page }]) .with_attached_thumbnail q = q.where('posts.url LIKE ?', "%#{ url }%") if url @@ -95,7 +95,7 @@ class PostsController < ApplicationController end def random - post = filtered_posts.preload(tags: { tag_name: :wiki_page }) + post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }]) .order('RAND()') .first return head :not_found unless post @@ -104,7 +104,7 @@ class PostsController < ApplicationController end def show - post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id]) + post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id]) return head :not_found unless post render json: PostRepr.base(post, current_user) diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index d4421e8..8c3411b 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -62,7 +62,7 @@ export default (({ post, onSave }: Props) => { setTitle (ev.target.value)}/> diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 7ee14c0..57907dc 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -1,6 +1,6 @@ import { apiDelete, apiGet, apiPost } from '@/lib/api' -import type { FetchPostsParams, Post, PostTagChange } from '@/types' +import type { FetchPostsParams, Post, PostVersion } from '@/types' export const fetchPosts = async ( @@ -29,17 +29,17 @@ export const fetchPost = async (id: string): Promise => await apiGet (`/po export const fetchPostChanges = async ( - { id, tag, page, limit }: { - id?: string + { post, tag, page, limit }: { + post?: string tag?: string page: number limit: number }, ): Promise<{ - changes: PostTagChange[] + versions: PostVersion[] count: number }> => - await apiGet ('/posts/changes', { params: { ...(id && { id }), - ...(tag && { tag }), - page, limit } }) + await apiGet ('/posts/versions', { params: { ...(post && { post }), + ...(tag && { tag }), + page, limit } }) export const toggleViewedFlg = async (id: string, viewed: boolean): Promise => { diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 65a8be5..610c847 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -5,7 +5,7 @@ export const postsKeys = { index: (p: FetchPostsParams) => ['posts', 'index', p] as const, show: (id: string) => ['posts', id] as const, related: (id: string) => ['related', id] as const, - changes: (p: { id?: string; tag?: string; page: number; limit: number }) => + changes: (p: { post?: string; tag?: string; page: number; limit: number }) => ['posts', 'changes', p] as const } export const tagsKeys = { diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 51d9b15..50a19d9 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -96,7 +96,7 @@ export default (({ user }: Props) => {
{(post?.thumbnail || post?.thumbnailBase) && ( - )} + )} {post && {`${ post.title || post.url } | ${ SITE_TITLE }`}} @@ -116,7 +116,7 @@ export default (({ user }: Props) => { initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: .2, ease: 'easeOut' }}> - {post.title diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index 4652441..ad61308 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' import { useEffect } from 'react' import { Helmet } from 'react-helmet-async' @@ -9,15 +9,30 @@ 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 { apiPut } from '@/lib/api' import { fetchPostChanges } from '@/lib/posts' import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { fetchTag } from '@/lib/tags' -import { cn, dateString } from '@/lib/utils' +import { cn, dateString, originalCreatedAtString } from '@/lib/utils' import type { FC } from 'react' +const renderDiff = (diff: { current: string | null; prev: string | null }) => ( + <> + {(diff.prev && diff.prev !== diff.current) && ( + <> + + {diff.prev} + + {diff.current &&
} + )} + {diff.current} + ) + + export default (() => { const location = useLocation () const query = new URLSearchParams (location.search) @@ -36,15 +51,17 @@ export default (() => { : { data: null } const { data, isLoading: loading } = useQuery ({ - queryKey: postsKeys.changes ({ ...(id && { id }), + queryKey: postsKeys.changes ({ ...(id && { post: id }), ...(tagId && { tag: tagId }), page, limit }), - queryFn: () => fetchPostChanges ({ ...(id && { id }), + queryFn: () => fetchPostChanges ({ ...(id && { post: id }), ...(tagId && { tag: tagId }), page, limit }) }) - const changes = data?.changes ?? [] + 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]) @@ -65,76 +82,164 @@ export default (() => { {loading ? 'Loading...' : ( <> - - - - - - - - - - {changes.map ((change, i) => { - const withPost = i === 0 || change.post.id !== changes[i - 1].post.id - if (withPost) - { - rowsCnt = 1 - for (let j = i + 1; - (j < changes.length - && change.post.id === changes[j].post.id); - ++j) - ++rowsCnt - } - - let layoutId: string | undefined = `page-${ change.post.id }` - if (layoutIds.includes (layoutId)) - layoutId = undefined - else - layoutIds.push (layoutId) - - return ( - - {withPost && ( - )} - - - ) - })} - -
投稿変更日時
- - - {change.post.title - - - - {change.tag - ? - : '(マスタ削除済のタグ) '} - {`を${ change.changeType === 'add' ? '記載' : '消除' }`} - - {change.user - ? ( - - {change.user.name} - ) - : 'bot 操作'} -
- {dateString (change.timestamp)} -
+
+ + + {/* 投稿 */} + + {/* 版 */} + + {/* タイトル */} + + {/* URL */} + + {/* タグ */} + + {/* オリジナルの投稿日時 */} + + {/* 更新日時 */} + + {/* (差戻ボタン) */} + + + + + + + + + + + + + + + + + {changes.map ((change, i) => { + const withPost = i === 0 || change.postId !== changes[i - 1].postId + if (withPost) + { + rowsCnt = 1 + for (let j = i + 1; + (j < changes.length + && change.postId === changes[j].postId); + ++j) + ++rowsCnt + } + + let layoutId: string | undefined = `page-${ change.postId }` + if (layoutIds.includes (layoutId)) + layoutId = undefined + else + layoutIds.push (layoutId) + + return ( + + {withPost && ( + )} + + + + + + + + ) + })} + +
投稿タイトルURLタグオリジナルの投稿日時更新日時 +
+ + + {change.title.current + + + {change.postId}.{change.versionNo}{renderDiff (change.title)}{renderDiff (change.url)} + {change.tags.map ((tag, i) => ( + tag.type === 'added' + ? ( + + {tag.name} + ) + : ( + tag.type === 'removed' + ? ( + + {tag.name} + ) + : ( + + {tag.name} + ))))} + + {change.versionNo === 1 + ? originalCreatedAtString (change.originalCreatedFrom.current, + change.originalCreatedBefore.current) + : renderDiff ({ + current: originalCreatedAtString ( + change.originalCreatedFrom.current, + change.originalCreatedBefore.current), + prev: originalCreatedAtString ( + change.originalCreatedFrom.prev, + change.originalCreatedBefore.prev) })} + + {change.createdByUser + ? ( + + {change.createdByUser.name + || `名もなきニジラー(#${ change.createdByUser.id })`} + ) + : 'bot 操作'} +
+ {dateString (change.createdAt)} +
+ { + e.preventDefault () + + if (!(confirm ( + `『${ change.title.current + || change.url.current }』を版 ${ + change.versionNo } に差戻します.\nよろしいですか?`))) + return + + await apiPut ( + `/posts/${ change.postId }`, + { title: change.title.current, + tags: change.tags + .filter (t => t.type !== 'removed') + .map (t => t.name) + .filter (t => t.slice (0, 5) !== 'nico:') + .join (' '), + original_created_from: + change.originalCreatedFrom.current, + original_created_before: + change.originalCreatedBefore.current }) + + qc.invalidateQueries ({ queryKey: postsKeys.root }) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) + toast ({ description: '更新しました.' }) + }}> + 復元 + +
+
)} diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 73071cc..419c134 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -289,7 +289,7 @@ export default (() => { {results.map (row => ( - + { - + {row.title} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 57e2dd5..12f8838 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -117,9 +117,9 @@ export type NiconicoViewerHandle = { export type Post = { id: number url: string - title: string - thumbnail: string - thumbnailBase: string + title: string | null + thumbnail: string | null + thumbnailBase: string | null tags: Tag[] viewed: boolean related: Post[] @@ -127,7 +127,7 @@ export type Post = { originalCreatedBefore: string | null createdAt: string updatedAt: string - uploadedUser: { id: number; name: string } | null } + uploadedUser: { id: number; name: string | null } | null } export type PostTagChange = { post: Post @@ -140,14 +140,13 @@ export type PostVersion = { postId: number versionNo: number eventType: 'create' | 'update' | 'discard' | 'restore' - title: string - url: string - thumbnail: string - thumbnailBase: string - tags: Tag[] - parent: Post | null - originalCreatedFrom: string | null - originalCreatedBefore: string | null + title: { current: string | null; prev: string | null } + url: { current: string; prev: string | null } + thumbnail: { current: string | null; prev: string | null } + thumbnailBase: { current: string | null; prev: string | null } + tags: { name: string; type: 'context' | 'added' | 'removed' }[] + originalCreatedFrom: { current: string | null; prev: string | null } + originalCreatedBefore: { current: string | null; prev: string | null } createdAt: string createdByUser: { id: number; name: string | null } | null }