#112 現在ページの表示を太く #112 完了 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/177feature/176
| @@ -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 | |||
| @@ -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' | |||
| @@ -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 (() => { | |||
| <Route path="/posts" element={<PostListPage/>}/> | |||
| <Route path="/posts/new" element={<PostNewPage 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="/wiki" element={<WikiSearchPage/>}/> | |||
| <Route path="/wiki/:title" element={<WikiDetailPage/>}/> | |||
| @@ -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 }, | |||
| @@ -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 | |||
| originalCreatedBefore: string | null } | |||
| export type PostTagChange = { | |||
| post: Post | |||
| tag: Tag | |||
| user?: User | |||
| changeType: 'add' | 'remove' | |||
| timestamp: string } | |||
| export type SubMenuItem = | |||
| | { component: ReactNode | |||
| visible: boolean } | |||