| @@ -1,4 +1,6 @@ | |||||
| class PostsController < ApplicationController | class PostsController < ApplicationController | ||||
| Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) | |||||
| # GET /posts | # GET /posts | ||||
| def index | def index | ||||
| limit = params[:limit].presence&.to_i | limit = params[:limit].presence&.to_i | ||||
| @@ -128,6 +130,44 @@ class PostsController < ApplicationController | |||||
| def destroy | def destroy | ||||
| end | 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 | private | ||||
| def filtered_posts | def filtered_posts | ||||
| @@ -4,6 +4,7 @@ Rails.application.routes.draw do | |||||
| get 'tags/autocomplete', to: 'tags#autocomplete' | get 'tags/autocomplete', to: 'tags#autocomplete' | ||||
| get 'tags/name/:name', to: 'tags#show_by_name' | get 'tags/name/:name', to: 'tags#show_by_name' | ||||
| get 'posts/random', to: 'posts#random' | get 'posts/random', to: 'posts#random' | ||||
| get 'posts/changes', to: 'posts#changes' | |||||
| post 'posts/:id/viewed', to: 'posts#viewed' | post 'posts/:id/viewed', to: 'posts#viewed' | ||||
| delete 'posts/:id/viewed', to: 'posts#unviewed' | delete 'posts/:id/viewed', to: 'posts#unviewed' | ||||
| get 'preview/title', to: 'preview#title' | get 'preview/title', to: 'preview#title' | ||||
| @@ -9,6 +9,7 @@ import { API_BASE_URL } from '@/config' | |||||
| import NicoTagListPage from '@/pages/tags/NicoTagListPage' | import NicoTagListPage from '@/pages/tags/NicoTagListPage' | ||||
| import NotFound from '@/pages/NotFound' | import NotFound from '@/pages/NotFound' | ||||
| import PostDetailPage from '@/pages/posts/PostDetailPage' | import PostDetailPage from '@/pages/posts/PostDetailPage' | ||||
| import PostHistoryPage from '@/pages/posts/PostHistoryPage' | |||||
| import PostListPage from '@/pages/posts/PostListPage' | import PostListPage from '@/pages/posts/PostListPage' | ||||
| import PostNewPage from '@/pages/posts/PostNewPage' | import PostNewPage from '@/pages/posts/PostNewPage' | ||||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | import ServiceUnavailable from '@/pages/ServiceUnavailable' | ||||
| @@ -79,6 +80,7 @@ export default (() => { | |||||
| <Route path="/posts" element={<PostListPage/>}/> | <Route path="/posts" element={<PostListPage/>}/> | ||||
| <Route path="/posts/new" element={<PostNewPage user={user}/>}/> | <Route path="/posts/new" element={<PostNewPage user={user}/>}/> | ||||
| <Route path="/posts/:id" element={<PostDetailPage user={user}/>}/> | <Route path="/posts/:id" element={<PostDetailPage user={user}/>}/> | ||||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | |||||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | ||||
| <Route path="/wiki" element={<WikiSearchPage/>}/> | <Route path="/wiki" element={<WikiSearchPage/>}/> | ||||
| <Route path="/wiki/:title" element={<WikiDetailPage/>}/> | <Route path="/wiki/:title" element={<WikiDetailPage/>}/> | ||||
| @@ -30,6 +30,7 @@ export default (({ user }: Props) => { | |||||
| { name: '広場', to: '/posts', subMenu: [ | { name: '広場', to: '/posts', subMenu: [ | ||||
| { name: '一覧', to: '/posts' }, | { name: '一覧', to: '/posts' }, | ||||
| { name: '投稿追加', to: '/posts/new' }, | { name: '投稿追加', to: '/posts/new' }, | ||||
| { name: '耕作履歴', to: '/posts/changes' }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | ||||
| { name: 'タグ', to: '/tags', subMenu: [ | { name: 'タグ', to: '/tags', subMenu: [ | ||||
| { name: 'タグ一覧', to: '/tags', visible: false }, | { name: 'タグ一覧', to: '/tags', visible: false }, | ||||
| @@ -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 ( | |||||
| <nav className="mt-4 flex justify-center" aria-label="Pagination"> | |||||
| <div className="flex items-center gap-2"> | |||||
| {(page > 1) | |||||
| ? <Link to={buildTo (page - 1)} aria-label="前のページ"><</Link> | |||||
| : <span aria-hidden><</span>} | |||||
| {pages.map ((p, idx) => ( | |||||
| (p === '…') | |||||
| ? <span key={`dots-${ idx }`}>…</span> | |||||
| : ((p === page) | |||||
| ? <span key={p} className="font-bold" aria-current="page">{p}</span> | |||||
| : <Link key={p} to={buildTo (p)}>{p}</Link>)))} | |||||
| {(page < totalPages) | |||||
| ? <Link to={buildTo (page + 1)} aria-label="次のページ">></Link> | |||||
| : <span aria-hidden>></span>} | |||||
| </div> | |||||
| </nav>) | |||||
| }) satisfies FC<Props> | |||||
| @@ -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<PostTagChange[]> ([]) | |||||
| const [totalPages, setTotalPages] = useState<number> (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 ( | |||||
| <MainArea> | |||||
| <Helmet> | |||||
| <title>{`耕作履歴 | ${ SITE_TITLE }`}</title> | |||||
| </Helmet> | |||||
| <PageTitle> | |||||
| 耕作履歴 | |||||
| {Boolean (id) && <>: 投稿 {<Link to={`/posts/${ id }`}>#{id}</Link>}</>} | |||||
| </PageTitle> | |||||
| <table className="table-auto w-full border-collapse"> | |||||
| <thead> | |||||
| <tr> | |||||
| <th className="p-2 text-left">投稿</th> | |||||
| <th className="p-2 text-left">変更</th> | |||||
| <th className="p-2 text-left">日時</th> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| {changes.map (change => ( | |||||
| <tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}> | |||||
| <td> | |||||
| <Link to={`/posts/${ change.post.id }`}> | |||||
| <img src={change.post.thumbnail || change.post.thumbnailBase || undefined} | |||||
| alt={change.post.title || change.post.url} | |||||
| title={change.post.title || change.post.url || undefined} | |||||
| className="w-40"/> | |||||
| </Link> | |||||
| </td> | |||||
| <td> | |||||
| <TagLink tag={change.tag} withWiki={false} withCount={false}/> | |||||
| {`を${ change.changeType === 'add' ? '追加' : '削除' }`} | |||||
| </td> | |||||
| <td> | |||||
| {change.user ? ( | |||||
| <Link to={`/users/${ change.user.id }`}> | |||||
| {change.user.name} | |||||
| </Link>) : 'bot 操作'} | |||||
| <br/> | |||||
| {change.timestamp} | |||||
| </td> | |||||
| </tr>))} | |||||
| </tbody> | |||||
| </table> | |||||
| <Pagination page={page} totalPages={totalPages}/> | |||||
| </MainArea>) | |||||
| }) satisfies FC | |||||
| @@ -29,6 +29,13 @@ export type Post = { | |||||
| originalCreatedFrom: string | null | originalCreatedFrom: string | null | ||||
| originalCreatedBefore: string | null } | originalCreatedBefore: string | null } | ||||
| export type PostTagChange = { | |||||
| post: Post | |||||
| tag: Tag | |||||
| user?: User | |||||
| changeType: 'add' | 'remove' | |||||
| timestamp: string } | |||||
| export type SubMenuItem = | export type SubMenuItem = | ||||
| | { component: ReactNode | | { component: ReactNode | ||||
| visible: boolean } | visible: boolean } | ||||