diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index a09028a..96af1c1 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -1,4 +1,6 @@ class PostsController < ApplicationController + Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) + # GET /posts def index limit = params[:limit].presence&.to_i @@ -128,6 +130,44 @@ class PostsController < ApplicationController def destroy end + def changes + id = params[:id] + 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 + + pts = PostTag.with_discarded + pts = pts.where(post_id: id) if id.present? + pts = pts.includes(:post, :tag, :created_user, :deleted_user) + + events = [] + pts.each do |pt| + events << Event.new( + post: pt.post, + tag: pt.tag, + user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name }, + change_type: 'add', + timestamp: pt.created_at) + + if pt.discarded_at + events << Event.new( + post: pt.post, + tag: pt.tag, + user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name }, + change_type: 'remove', + timestamp: pt.discarded_at) + end + end + events.sort_by!(&:timestamp) + events.reverse! + + render json: { changes: events.slice(offset, limit).as_json, count: events.size } + end + private def filtered_posts diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 709f1d0..0733991 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -4,6 +4,7 @@ Rails.application.routes.draw do get 'tags/autocomplete', to: 'tags#autocomplete' get 'tags/name/:name', to: 'tags#show_by_name' get 'posts/random', to: 'posts#random' + get 'posts/changes', to: 'posts#changes' post 'posts/:id/viewed', to: 'posts#viewed' delete 'posts/:id/viewed', to: 'posts#unviewed' get 'preview/title', to: 'preview#title' diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d1b1e28..2195ca8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { API_BASE_URL } from '@/config' import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' import PostDetailPage from '@/pages/posts/PostDetailPage' +import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostListPage from '@/pages/posts/PostListPage' import PostNewPage from '@/pages/posts/PostNewPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' @@ -79,6 +80,7 @@ export default (() => { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 104f40e..e62be09 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -30,6 +30,7 @@ export default (({ user }: Props) => { { name: '広場', to: '/posts', subMenu: [ { name: '一覧', to: '/posts' }, { name: '投稿追加', to: '/posts/new' }, + { name: '耕作履歴', to: '/posts/changes' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'タグ', to: '/tags', subMenu: [ { name: 'タグ一覧', to: '/tags', visible: false }, diff --git a/frontend/src/components/common/Pagination.tsx b/frontend/src/components/common/Pagination.tsx new file mode 100644 index 0000000..7fccce2 --- /dev/null +++ b/frontend/src/components/common/Pagination.tsx @@ -0,0 +1,79 @@ +import { Link, useLocation } from 'react-router-dom' + +import type { FC } from 'react' + +type Props = { page: number + totalPages: number + siblingCount?: number } + + +const range = (start: number, end: number): number[] => + [...Array (end - start + 1).keys ()].map (i => start + i) + + +const getPages = ( + page: number, + total: number, + siblingCount: number, +): (number | '…')[] => { + if (total <= 1) + return [1] + + const first = 1 + const last = total + + const left = Math.max (page - siblingCount, first) + const right = Math.min (page + siblingCount, last) + + const pages: (number | '…')[] = [] + + pages.push (first) + + if (left > first + 1) + pages.push ('…') + + const midStart = Math.max (left, first + 1) + const midEnd = Math.min (right, last - 1) + pages.push (...range (midStart, midEnd)) + + if (right < last - 1) + pages.push ('…') + + if (last !== first) + pages.push (last) + + return pages.filter ((v, i, arr) => i === 0 || v !== arr[i - 1]) +} + + +export default (({ page, totalPages, siblingCount = 4 }) => { + const location = useLocation () + + const buildTo = (p: number) => { + const qs = new URLSearchParams (location.search) + qs.set ('page', String (p)) + return `${ location.pathname }?${ qs.toString () }` + } + + const pages = getPages (page, totalPages, siblingCount) + + return ( + ) +}) satisfies FC diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx new file mode 100644 index 0000000..e401b09 --- /dev/null +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -0,0 +1,90 @@ +import axios from 'axios' +import toCamel from 'camelcase-keys' +import { useEffect, useState } from 'react' +import { Helmet } from 'react-helmet-async' +import { Link, useLocation } from 'react-router-dom' + +import TagLink from '@/components/TagLink' +import PageTitle from '@/components/common/PageTitle' +import Pagination from '@/components/common/Pagination' +import MainArea from '@/components/layout/MainArea' +import { API_BASE_URL, SITE_TITLE } from '@/config' + +import type { FC } from 'react' + +import type { PostTagChange } from '@/types' + + +export default (() => { + const [changes, setChanges] = useState ([]) + const [totalPages, setTotalPages] = useState (0) + + 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) + + useEffect (() => { + void (async () => { + const res = await axios.get (`${ API_BASE_URL }/posts/changes`, + { params: { ...(id && { id }), + ...(page && { page }), + ...(limit && { limit }) } }) + const data = toCamel (res.data as any, { deep: true }) as { + changes: PostTagChange[] + count: number } + setChanges (data.changes) + setTotalPages (Math.trunc ((data.count - 1) / limit)) + }) () + }, [location.search]) + + return ( + + + {`耕作履歴 | ${ SITE_TITLE }`} + + + + 耕作履歴 + {Boolean (id) && <>: 投稿 {#{id}}} + + + + + + + + + + + + {changes.map (change => ( + + + + + ))} + +
投稿変更日時
+ + {change.post.title + + + + {`を${ change.changeType === 'add' ? '追加' : '削除' }`} + + {change.user ? ( + + {change.user.name} + ) : 'bot 操作'} +
+ {change.timestamp} +
+ + +
) +}) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5904f19..f78c85b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -29,6 +29,13 @@ export type Post = { originalCreatedFrom: string | null originalCreatedBefore: string | null } +export type PostTagChange = { + post: Post + tag: Tag + user?: User + changeType: 'add' | 'remove' + timestamp: string } + export type SubMenuItem = | { component: ReactNode visible: boolean }