Merge branch 'main' into feature/308 #308 #308 #308 #308 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/315main
| @@ -0,0 +1,119 @@ | |||||
| class PostVersionsController < ApplicationController | |||||
| def index | |||||
| post_id = params[:post].presence | |||||
| tag_id = params[:tag].presence | |||||
| 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 | |||||
| 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 :kw " + | |||||
| "OR CONCAT(' ', prev.tags, ' ') LIKE :kw"), | |||||
| kw: "% #{ escaped } %") | |||||
| end | |||||
| count = q.except(:select, :order, :limit, :offset).count | |||||
| versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC')) | |||||
| .limit(limit) | |||||
| .offset(offset) | |||||
| 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 | |||||
| @@ -44,7 +44,7 @@ class PostsController < ApplicationController | |||||
| filtered_posts | filtered_posts | ||||
| .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") | .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")) | .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 | .with_attached_thumbnail | ||||
| q = q.where('posts.url LIKE ?', "%#{ url }%") if url | q = q.where('posts.url LIKE ?', "%#{ url }%") if url | ||||
| @@ -95,7 +95,7 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| def random | def random | ||||
| post = filtered_posts.preload(tags: { tag_name: :wiki_page }) | |||||
| post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }]) | |||||
| .order('RAND()') | .order('RAND()') | ||||
| .first | .first | ||||
| return head :not_found unless post | return head :not_found unless post | ||||
| @@ -104,7 +104,7 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| def show | 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 | return head :not_found unless post | ||||
| render json: PostRepr.base(post, current_user) | render json: PostRepr.base(post, current_user) | ||||
| @@ -204,7 +204,7 @@ class PostsController < ApplicationController | |||||
| pts = pts.where(post_id: id) if id.present? | pts = pts.where(post_id: id) if id.present? | ||||
| pts = pts.where(tag_id:) if tag_id.present? | pts = pts.where(tag_id:) if tag_id.present? | ||||
| pts = pts.includes(:post, :created_user, :deleted_user, | pts = pts.includes(:post, :created_user, :deleted_user, | ||||
| tag: { tag_name: :wiki_page }) | |||||
| tag: [:materials, { tag_name: :wiki_page }]) | |||||
| events = [] | events = [] | ||||
| pts.each do |pt| | pts.each do |pt| | ||||
| @@ -49,6 +49,7 @@ Rails.application.routes.draw do | |||||
| collection do | collection do | ||||
| get :random | get :random | ||||
| get :changes | get :changes | ||||
| get :versions, to: 'post_versions#index' | |||||
| end | end | ||||
| member do | member do | ||||
| @@ -756,6 +756,218 @@ RSpec.describe 'Posts API', type: :request do | |||||
| end | end | ||||
| end | end | ||||
| describe 'GET /posts/versions' do | |||||
| let(:member) { create(:user, :member, name: 'version member') } | |||||
| let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) } | |||||
| let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) } | |||||
| let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) } | |||||
| let(:oc_from) { Time.zone.local(2019, 12, 31, 0, 0, 0) } | |||||
| let(:oc_before) { Time.zone.local(2020, 1, 1, 0, 0, 0) } | |||||
| let!(:tag_name2) { TagName.create!(name: 'spec_tag_2') } | |||||
| let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :general) } | |||||
| def snapshot_tags(post) | |||||
| post.snapshot_tag_names.join(' ') | |||||
| end | |||||
| def create_post_version!(post, version_no:, event_type:, created_by_user:, created_at:) | |||||
| PostVersion.create!( | |||||
| post: post, | |||||
| version_no: version_no, | |||||
| event_type: event_type, | |||||
| title: post.title, | |||||
| url: post.url, | |||||
| thumbnail_base: post.thumbnail_base, | |||||
| tags: snapshot_tags(post), | |||||
| parent: post.parent, | |||||
| original_created_from: post.original_created_from, | |||||
| original_created_before: post.original_created_before, | |||||
| created_at: created_at, | |||||
| created_by_user: created_by_user | |||||
| ) | |||||
| end | |||||
| let!(:v1) do | |||||
| travel_to(t_v1) do | |||||
| create_post_version!( | |||||
| post_record, | |||||
| version_no: 1, | |||||
| event_type: 'create', | |||||
| created_by_user: member, | |||||
| created_at: t_v1 | |||||
| ) | |||||
| end | |||||
| end | |||||
| let!(:v2) do | |||||
| post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member) | |||||
| PostTag.create!(post: post_record, tag: tag2, created_user: member) | |||||
| post_record.update!( | |||||
| title: 'updated spec post', | |||||
| original_created_from: oc_from, | |||||
| original_created_before: oc_before | |||||
| ) | |||||
| travel_to(t_v2) do | |||||
| create_post_version!( | |||||
| post_record.reload, | |||||
| version_no: 2, | |||||
| event_type: 'update', | |||||
| created_by_user: member, | |||||
| created_at: t_v2 | |||||
| ) | |||||
| end | |||||
| end | |||||
| let!(:other_post_version) do | |||||
| other_post = Post.create!( | |||||
| title: 'other versioned post', | |||||
| url: 'https://example.com/other-versioned' | |||||
| ) | |||||
| PostTag.create!(post: other_post, tag: tag) | |||||
| travel_to(t_other) do | |||||
| create_post_version!( | |||||
| other_post, | |||||
| version_no: 1, | |||||
| event_type: 'create', | |||||
| created_by_user: member, | |||||
| created_at: t_other | |||||
| ) | |||||
| end | |||||
| end | |||||
| it 'returns versions for the specified post in reverse chronological order' do | |||||
| get '/posts/versions', params: { post: post_record.id } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json).to include('versions', 'count') | |||||
| expect(json.fetch('count')).to eq(2) | |||||
| versions = json.fetch('versions') | |||||
| expect(versions.map { |v| v['post_id'] }.uniq).to eq([post_record.id]) | |||||
| expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) | |||||
| latest = versions.first | |||||
| expect(latest).to include( | |||||
| 'post_id' => post_record.id, | |||||
| 'version_no' => 2, | |||||
| 'event_type' => 'update', | |||||
| 'created_by_user' => { | |||||
| 'id' => member.id, | |||||
| 'name' => member.name | |||||
| } | |||||
| ) | |||||
| expect(latest.fetch('title')).to eq( | |||||
| 'current' => 'updated spec post', | |||||
| 'prev' => 'spec post' | |||||
| ) | |||||
| expect(latest.fetch('url')).to eq( | |||||
| 'current' => 'https://example.com/spec', | |||||
| 'prev' => 'https://example.com/spec' | |||||
| ) | |||||
| expect(latest.fetch('thumbnail')).to eq( | |||||
| 'current' => nil, | |||||
| 'prev' => nil | |||||
| ) | |||||
| expect(latest.fetch('thumbnail_base')).to eq( | |||||
| 'current' => nil, | |||||
| 'prev' => nil | |||||
| ) | |||||
| expect(latest.fetch('tags')).to include( | |||||
| { 'name' => 'spec_tag_2', 'type' => 'added' }, | |||||
| { 'name' => 'spec_tag', 'type' => 'removed' } | |||||
| ) | |||||
| expect(latest.fetch('original_created_from')).to eq( | |||||
| 'current' => oc_from.iso8601, | |||||
| 'prev' => nil | |||||
| ) | |||||
| expect(latest.fetch('original_created_before')).to eq( | |||||
| 'current' => oc_before.iso8601, | |||||
| 'prev' => nil | |||||
| ) | |||||
| expect(latest.fetch('created_at')).to eq(t_v2.iso8601) | |||||
| first = versions.second | |||||
| expect(first).to include( | |||||
| 'post_id' => post_record.id, | |||||
| 'version_no' => 1, | |||||
| 'event_type' => 'create', | |||||
| 'created_by_user' => { | |||||
| 'id' => member.id, | |||||
| 'name' => member.name | |||||
| } | |||||
| ) | |||||
| expect(first.fetch('title')).to eq( | |||||
| 'current' => 'spec post', | |||||
| 'prev' => nil | |||||
| ) | |||||
| expect(first.fetch('tags')).to include( | |||||
| { 'name' => 'spec_tag', 'type' => 'added' } | |||||
| ) | |||||
| expect(first.fetch('created_at')).to eq(t_v1.iso8601) | |||||
| end | |||||
| it 'filters versions by tag when the current snapshot includes the tag' do | |||||
| get '/posts/versions', params: { post: post_record.id, tag: tag2.id } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json.fetch('count')).to eq(1) | |||||
| versions = json.fetch('versions') | |||||
| expect(versions.size).to eq(1) | |||||
| expect(versions[0]['post_id']).to eq(post_record.id) | |||||
| expect(versions[0]['version_no']).to eq(2) | |||||
| expect(versions[0]['tags']).to include( | |||||
| { 'name' => 'spec_tag_2', 'type' => 'added' } | |||||
| ) | |||||
| end | |||||
| it 'filters versions by tag when the tag exists in either current or previous snapshot' do | |||||
| get '/posts/versions', params: { post: post_record.id, tag: tag.id } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json.fetch('count')).to eq(2) | |||||
| versions = json.fetch('versions') | |||||
| expect(versions.map { |v| v['post_id'] }).to all(eq(post_record.id)) | |||||
| expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) | |||||
| latest = versions[0] | |||||
| first = versions[1] | |||||
| expect(latest['tags']).to include( | |||||
| { 'name' => 'spec_tag', 'type' => 'removed' } | |||||
| ) | |||||
| expect(first['tags']).to include( | |||||
| { 'name' => 'spec_tag', 'type' => 'added' } | |||||
| ) | |||||
| end | |||||
| it 'returns empty when tag does not exist' do | |||||
| get '/posts/versions', params: { tag: 999_999_999 } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json.fetch('versions')).to eq([]) | |||||
| expect(json.fetch('count')).to eq(0) | |||||
| end | |||||
| it 'clamps page and limit to at least 1' do | |||||
| get '/posts/versions', params: { post: post_record.id, page: 0, limit: 0 } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json.fetch('count')).to eq(2) | |||||
| versions = json.fetch('versions') | |||||
| expect(versions.size).to eq(1) | |||||
| expect(versions[0]['version_no']).to eq(2) | |||||
| end | |||||
| end | |||||
| describe 'POST /posts/:id/viewed' do | describe 'POST /posts/:id/viewed' do | ||||
| let(:user) { create(:user) } | let(:user) { create(:user) } | ||||
| @@ -90,7 +90,9 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: | |||||
| className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} | className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} | ||||
| {...attributes} | {...attributes} | ||||
| {...listeners}> | {...listeners}> | ||||
| <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> | |||||
| <motion.div | |||||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }} | |||||
| layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> | |||||
| <TagLink tag={tag} nestLevel={nestLevel}/> | <TagLink tag={tag} nestLevel={nestLevel}/> | ||||
| </motion.div> | </motion.div> | ||||
| </div>) | </div>) | ||||
| @@ -62,7 +62,7 @@ export default (({ post, onSave }: Props) => { | |||||
| <Label>タイトル</Label> | <Label>タイトル</Label> | ||||
| <input type="text" | <input type="text" | ||||
| className="w-full border rounded p-2" | className="w-full border rounded p-2" | ||||
| value={title} | |||||
| value={title ?? ''} | |||||
| onChange={ev => setTitle (ev.target.value)}/> | onChange={ev => setTitle (ev.target.value)}/> | ||||
| </div> | </div> | ||||
| @@ -313,7 +313,9 @@ export default (({ post, sp }: Props) => { | |||||
| {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( | {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( | ||||
| <div className="my-3" key={cat}> | <div className="my-3" key={cat}> | ||||
| <SubsectionTitle> | <SubsectionTitle> | ||||
| <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}> | |||||
| <motion.div | |||||
| layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`} | |||||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }}> | |||||
| {CATEGORY_NAMES[cat]} | {CATEGORY_NAMES[cat]} | ||||
| </motion.div> | </motion.div> | ||||
| </SubsectionTitle> | </SubsectionTitle> | ||||
| @@ -325,7 +327,9 @@ export default (({ post, sp }: Props) => { | |||||
| </ul> | </ul> | ||||
| </div>))} | </div>))} | ||||
| {post && ( | {post && ( | ||||
| <motion.div layoutId={`post-info-${ sp }`}> | |||||
| <motion.div | |||||
| layoutId={`post-info-${ sp }`} | |||||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }}> | |||||
| <SectionTitle>情報</SectionTitle> | <SectionTitle>情報</SectionTitle> | ||||
| <ul> | <ul> | ||||
| <li>Id.: {post.id}</li> | <li>Id.: {post.id}</li> | ||||
| @@ -1,6 +1,6 @@ | |||||
| import { apiDelete, apiGet, apiPost } from '@/lib/api' | 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 ( | export const fetchPosts = async ( | ||||
| @@ -29,17 +29,17 @@ export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/po | |||||
| export const fetchPostChanges = async ( | export const fetchPostChanges = async ( | ||||
| { id, tag, page, limit }: { | |||||
| id?: string | |||||
| { post, tag, page, limit }: { | |||||
| post?: string | |||||
| tag?: string | tag?: string | ||||
| page: number | page: number | ||||
| limit: number }, | limit: number }, | ||||
| ): Promise<{ | ): Promise<{ | ||||
| changes: PostTagChange[] | |||||
| versions: PostVersion[] | |||||
| count: number }> => | 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<void> => { | export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { | ||||
| @@ -5,7 +5,7 @@ export const postsKeys = { | |||||
| index: (p: FetchPostsParams) => ['posts', 'index', p] as const, | index: (p: FetchPostsParams) => ['posts', 'index', p] as const, | ||||
| show: (id: string) => ['posts', id] as const, | show: (id: string) => ['posts', id] as const, | ||||
| related: (id: string) => ['related', 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 } | ['posts', 'changes', p] as const } | ||||
| export const tagsKeys = { | export const tagsKeys = { | ||||
| @@ -96,7 +96,7 @@ export default (({ user }: Props) => { | |||||
| <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> | <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> | ||||
| <Helmet> | <Helmet> | ||||
| {(post?.thumbnail || post?.thumbnailBase) && ( | {(post?.thumbnail || post?.thumbnailBase) && ( | ||||
| <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} | |||||
| <meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)} | |||||
| {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} | {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} | ||||
| </Helmet> | </Helmet> | ||||
| @@ -116,7 +116,7 @@ export default (({ user }: Props) => { | |||||
| initial={{ opacity: 1 }} | initial={{ opacity: 1 }} | ||||
| animate={{ opacity: 0 }} | animate={{ opacity: 0 }} | ||||
| transition={{ duration: .2, ease: 'easeOut' }}> | transition={{ duration: .2, ease: 'easeOut' }}> | ||||
| <img src={post.thumbnail || post.thumbnailBase} | |||||
| <img src={post.thumbnail || post.thumbnailBase || undefined} | |||||
| alt={post.title || post.url} | alt={post.title || post.url} | ||||
| title={post.title || post.url || undefined} | title={post.title || post.url || undefined} | ||||
| className="object-cover w-full h-full"/> | className="object-cover w-full h-full"/> | ||||
| @@ -1,4 +1,4 @@ | |||||
| import { useQuery } from '@tanstack/react-query' | |||||
| import { useQuery, useQueryClient } from '@tanstack/react-query' | |||||
| import { motion } from 'framer-motion' | import { motion } from 'framer-motion' | ||||
| import { useEffect } from 'react' | import { useEffect } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| @@ -9,15 +9,30 @@ import PrefetchLink from '@/components/PrefetchLink' | |||||
| import PageTitle from '@/components/common/PageTitle' | import PageTitle from '@/components/common/PageTitle' | ||||
| import Pagination from '@/components/common/Pagination' | import Pagination from '@/components/common/Pagination' | ||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { toast } from '@/components/ui/use-toast' | |||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { apiPut } from '@/lib/api' | |||||
| import { fetchPostChanges } from '@/lib/posts' | import { fetchPostChanges } from '@/lib/posts' | ||||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | import { postsKeys, tagsKeys } from '@/lib/queryKeys' | ||||
| import { fetchTag } from '@/lib/tags' | import { fetchTag } from '@/lib/tags' | ||||
| import { cn, dateString } from '@/lib/utils' | |||||
| import { cn, dateString, originalCreatedAtString } from '@/lib/utils' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | |||||
| <> | |||||
| {(diff.prev && diff.prev !== diff.current) && ( | |||||
| <> | |||||
| <del className="text-red-600 dark:text-red-400"> | |||||
| {diff.prev} | |||||
| </del> | |||||
| {diff.current && <br/>} | |||||
| </>)} | |||||
| {diff.current} | |||||
| </>) | |||||
| export default (() => { | export default (() => { | ||||
| const location = useLocation () | const location = useLocation () | ||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| @@ -36,15 +51,17 @@ export default (() => { | |||||
| : { data: null } | : { data: null } | ||||
| const { data, isLoading: loading } = useQuery ({ | const { data, isLoading: loading } = useQuery ({ | ||||
| queryKey: postsKeys.changes ({ ...(id && { id }), | |||||
| queryKey: postsKeys.changes ({ ...(id && { post: id }), | |||||
| ...(tagId && { tag: tagId }), | ...(tagId && { tag: tagId }), | ||||
| page, limit }), | page, limit }), | ||||
| queryFn: () => fetchPostChanges ({ ...(id && { id }), | |||||
| queryFn: () => fetchPostChanges ({ ...(id && { post: id }), | |||||
| ...(tagId && { tag: tagId }), | ...(tagId && { tag: tagId }), | ||||
| page, limit }) }) | page, limit }) }) | ||||
| const changes = data?.changes ?? [] | |||||
| const changes = data?.versions ?? [] | |||||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | const totalPages = data ? Math.ceil (data.count / limit) : 0 | ||||
| const qc = useQueryClient () | |||||
| useEffect (() => { | useEffect (() => { | ||||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | ||||
| }, [location.search]) | }, [location.search]) | ||||
| @@ -65,76 +82,171 @@ export default (() => { | |||||
| {loading ? 'Loading...' : ( | {loading ? 'Loading...' : ( | ||||
| <> | <> | ||||
| <table className="table-auto w-full border-collapse"> | |||||
| <thead className="border-b-2 border-black dark:border-white"> | |||||
| <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, 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 ( | |||||
| <tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag?.id }`} | |||||
| className={cn ('even:bg-gray-100 dark:even:bg-gray-700', | |||||
| withPost && 'border-t')}> | |||||
| {withPost && ( | |||||
| <td className="align-top p-2 bg-white dark:bg-[#242424] border-r" | |||||
| rowSpan={rowsCnt}> | |||||
| <PrefetchLink to={`/posts/${ change.post.id }`}> | |||||
| <motion.div | |||||
| layoutId={layoutId} | |||||
| transition={{ type: 'spring', | |||||
| stiffness: 500, | |||||
| damping: 40, | |||||
| mass: .5 }}> | |||||
| <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"/> | |||||
| </motion.div> | |||||
| </PrefetchLink> | |||||
| </td>)} | |||||
| <td className="p-2"> | |||||
| {change.tag | |||||
| ? <TagLink tag={change.tag} withWiki={false} withCount={false}/> | |||||
| : '(マスタ削除済のタグ) '} | |||||
| {`を${ change.changeType === 'add' ? '記載' : '消除' }`} | |||||
| </td> | |||||
| <td className="p-2"> | |||||
| {change.user | |||||
| ? ( | |||||
| <PrefetchLink to={`/users/${ change.user.id }`}> | |||||
| {change.user.name} | |||||
| </PrefetchLink>) | |||||
| : 'bot 操作'} | |||||
| <br/> | |||||
| {dateString (change.timestamp)} | |||||
| </td> | |||||
| </tr>) | |||||
| })} | |||||
| </tbody> | |||||
| </table> | |||||
| <div className="overflow-x-auto"> | |||||
| <table className="w-full min-w-[1200px] table-fixed border-collapse"> | |||||
| <colgroup> | |||||
| {/* 投稿 */} | |||||
| <col className="w-64"/> | |||||
| {/* 版 */} | |||||
| <col className="w-40"/> | |||||
| {/* タイトル */} | |||||
| <col className="w-96"/> | |||||
| {/* URL */} | |||||
| <col className="w-96"/> | |||||
| {/* タグ */} | |||||
| <col className="w-[48rem]"/> | |||||
| {/* オリジナルの投稿日時 */} | |||||
| <col className="w-96"/> | |||||
| {/* 更新日時 */} | |||||
| <col className="w-64"/> | |||||
| {/* (差戻ボタン) */} | |||||
| <col className="w-20"/> | |||||
| </colgroup> | |||||
| <thead className="border-b-2 border-black dark:border-white"> | |||||
| <tr> | |||||
| <th className="p-2 text-left">投稿</th> | |||||
| <th className="p-2 text-left">版</th> | |||||
| <th className="p-2 text-left">タイトル</th> | |||||
| <th className="p-2 text-left">URL</th> | |||||
| <th className="p-2 text-left">タグ</th> | |||||
| <th className="p-2 text-left">オリジナルの投稿日時</th> | |||||
| <th className="p-2 text-left">更新日時</th> | |||||
| <th className="p-2"/> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| {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 ( | |||||
| <tr key={`${ change.postId }.${ change.versionNo }`} | |||||
| className={cn ('even:bg-gray-100 dark:even:bg-gray-700', | |||||
| withPost && 'border-t')}> | |||||
| {withPost && ( | |||||
| <td className="align-top p-2 bg-white dark:bg-[#242424] border-r" | |||||
| rowSpan={rowsCnt}> | |||||
| <PrefetchLink to={`/posts/${ change.postId }`}> | |||||
| <motion.div | |||||
| layoutId={layoutId} | |||||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }}> | |||||
| <img src={change.thumbnail.current | |||||
| || change.thumbnailBase.current | |||||
| || undefined} | |||||
| alt={change.title.current || change.url.current} | |||||
| title={change.title.current || change.url.current || undefined} | |||||
| className="w-40"/> | |||||
| </motion.div> | |||||
| </PrefetchLink> | |||||
| </td>)} | |||||
| <td className="p-2">{change.postId}.{change.versionNo}</td> | |||||
| <td className="p-2 break-all">{renderDiff (change.title)}</td> | |||||
| <td className="p-2 break-all">{renderDiff (change.url)}</td> | |||||
| <td className="p-2"> | |||||
| {change.tags.map ((tag, i) => ( | |||||
| tag.type === 'added' | |||||
| ? ( | |||||
| <ins | |||||
| key={i} | |||||
| className="mr-2 text-green-600 dark:text-green-400"> | |||||
| {tag.name} | |||||
| </ins>) | |||||
| : ( | |||||
| tag.type === 'removed' | |||||
| ? ( | |||||
| <del | |||||
| key={i} | |||||
| className="mr-2 text-red-600 dark:text-red-400"> | |||||
| {tag.name} | |||||
| </del>) | |||||
| : ( | |||||
| <span key={i} className="mr-2"> | |||||
| {tag.name} | |||||
| </span>))))} | |||||
| </td> | |||||
| <td className="p-2"> | |||||
| {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) })} | |||||
| </td> | |||||
| <td className="p-2"> | |||||
| {change.createdByUser | |||||
| ? ( | |||||
| <PrefetchLink to={`/users/${ change.createdByUser.id }`}> | |||||
| {change.createdByUser.name | |||||
| || `名もなきニジラー(#${ change.createdByUser.id })`} | |||||
| </PrefetchLink>) | |||||
| : 'bot 操作'} | |||||
| <br/> | |||||
| {dateString (change.createdAt)} | |||||
| </td> | |||||
| <td className="p-2"> | |||||
| <a | |||||
| href="#" | |||||
| onClick={async e => { | |||||
| e.preventDefault () | |||||
| if (!(confirm ( | |||||
| `『${ change.title.current | |||||
| || change.url.current }』を版 ${ | |||||
| change.versionNo } に差戻します.\nよろしいですか?`))) | |||||
| return | |||||
| try | |||||
| { | |||||
| 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: '差戻しました.' }) | |||||
| } | |||||
| catch | |||||
| { | |||||
| toast ({ description: '差戻に失敗……' }) | |||||
| } | |||||
| }}> | |||||
| 復元 | |||||
| </a> | |||||
| </td> | |||||
| </tr>) | |||||
| })} | |||||
| </tbody> | |||||
| </table> | |||||
| </div> | |||||
| <Pagination page={page} totalPages={totalPages}/> | <Pagination page={page} totalPages={totalPages}/> | ||||
| </>)} | </>)} | ||||
| @@ -289,7 +289,7 @@ export default (() => { | |||||
| {results.map (row => ( | {results.map (row => ( | ||||
| <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700"> | <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700"> | ||||
| <td className="p-2"> | <td className="p-2"> | ||||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> | |||||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}> | |||||
| <motion.div | <motion.div | ||||
| layoutId={`page-${ row.id }`} | layoutId={`page-${ row.id }`} | ||||
| transition={{ type: 'spring', | transition={{ type: 'spring', | ||||
| @@ -304,7 +304,7 @@ export default (() => { | |||||
| </PrefetchLink> | </PrefetchLink> | ||||
| </td> | </td> | ||||
| <td className="p-2 truncate"> | <td className="p-2 truncate"> | ||||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> | |||||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}> | |||||
| {row.title} | {row.title} | ||||
| </PrefetchLink> | </PrefetchLink> | ||||
| </td> | </td> | ||||
| @@ -117,9 +117,9 @@ export type NiconicoViewerHandle = { | |||||
| export type Post = { | export type Post = { | ||||
| id: number | id: number | ||||
| url: string | url: string | ||||
| title: string | |||||
| thumbnail: string | |||||
| thumbnailBase: string | |||||
| title: string | null | |||||
| thumbnail: string | null | |||||
| thumbnailBase: string | null | |||||
| tags: Tag[] | tags: Tag[] | ||||
| viewed: boolean | viewed: boolean | ||||
| related: Post[] | related: Post[] | ||||
| @@ -127,7 +127,7 @@ export type Post = { | |||||
| originalCreatedBefore: string | null | originalCreatedBefore: string | null | ||||
| createdAt: string | createdAt: string | ||||
| updatedAt: string | updatedAt: string | ||||
| uploadedUser: { id: number; name: string } | null } | |||||
| uploadedUser: { id: number; name: string | null } | null } | |||||
| export type PostTagChange = { | export type PostTagChange = { | ||||
| post: Post | post: Post | ||||
| @@ -136,6 +136,20 @@ export type PostTagChange = { | |||||
| changeType: 'add' | 'remove' | changeType: 'add' | 'remove' | ||||
| timestamp: string } | timestamp: string } | ||||
| export type PostVersion = { | |||||
| postId: number | |||||
| versionNo: number | |||||
| eventType: 'create' | 'update' | 'discard' | 'restore' | |||||
| 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 } | |||||
| export type SubMenuComponentItem = { | export type SubMenuComponentItem = { | ||||
| component: ReactNode | component: ReactNode | ||||
| visible: boolean } | visible: boolean } | ||||