feat: 耕作履歴ページ作成(#112) #177
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user