| @@ -10,20 +10,108 @@ class PostVersionsController < ApplicationController | |||
| offset = (page - 1) * limit | |||
| tag_name = nil | |||
| if tag_id | |||
| tag_name = TagName.joins(:tag).find_by(tag: { id: tag_id }) | |||
| return render json: [] unless tag_name | |||
| 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 ?", "% #{ escaped } %") | |||
| end | |||
| q = PostVersion | |||
| q = q.where(post_id:) if post_id | |||
| q = q.where("CONCAT(' ', tags, ' ') LIKE ?", "% #{ tag_name } %") if tag_name | |||
| count = q.except(:select, :order, :limit, :offset).count | |||
| versions = q.order(created_at: :desc, id: :desc) | |||
| versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC')) | |||
| .limit(limit) | |||
| .offset(offset) | |||
| render json: { versions:, count: q.count } | |||
| 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 | |||
| .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")) | |||
| .preload(tags: { tag_name: :wiki_page }) | |||
| .preload(tags: [:materials, { tag_name: :wiki_page }]) | |||
| .with_attached_thumbnail | |||
| q = q.where('posts.url LIKE ?', "%#{ url }%") if url | |||
| @@ -95,7 +95,7 @@ class PostsController < ApplicationController | |||
| end | |||
| def random | |||
| post = filtered_posts.preload(tags: { tag_name: :wiki_page }) | |||
| post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }]) | |||
| .order('RAND()') | |||
| .first | |||
| return head :not_found unless post | |||
| @@ -104,7 +104,7 @@ class PostsController < ApplicationController | |||
| end | |||
| 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 | |||
| render json: PostRepr.base(post, current_user) | |||
| @@ -62,7 +62,7 @@ export default (({ post, onSave }: Props) => { | |||
| <Label>タイトル</Label> | |||
| <input type="text" | |||
| className="w-full border rounded p-2" | |||
| value={title} | |||
| value={title ?? ''} | |||
| onChange={ev => setTitle (ev.target.value)}/> | |||
| </div> | |||
| @@ -1,6 +1,6 @@ | |||
| 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 ( | |||
| @@ -29,17 +29,17 @@ export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/po | |||
| export const fetchPostChanges = async ( | |||
| { id, tag, page, limit }: { | |||
| id?: string | |||
| { post, tag, page, limit }: { | |||
| post?: string | |||
| tag?: string | |||
| page: number | |||
| limit: number }, | |||
| ): Promise<{ | |||
| changes: PostTagChange[] | |||
| versions: PostVersion[] | |||
| 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> => { | |||
| @@ -5,7 +5,7 @@ export const postsKeys = { | |||
| index: (p: FetchPostsParams) => ['posts', 'index', p] as const, | |||
| show: (id: string) => ['posts', 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 } | |||
| 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"> | |||
| <Helmet> | |||
| {(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>} | |||
| </Helmet> | |||
| @@ -116,7 +116,7 @@ export default (({ user }: Props) => { | |||
| initial={{ opacity: 1 }} | |||
| animate={{ opacity: 0 }} | |||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||
| <img src={post.thumbnail || post.thumbnailBase} | |||
| <img src={post.thumbnail || post.thumbnailBase || undefined} | |||
| alt={post.title || post.url} | |||
| title={post.title || post.url || undefined} | |||
| 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 { useEffect } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| @@ -9,15 +9,30 @@ import PrefetchLink from '@/components/PrefetchLink' | |||
| import PageTitle from '@/components/common/PageTitle' | |||
| import Pagination from '@/components/common/Pagination' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { apiPut } from '@/lib/api' | |||
| import { fetchPostChanges } from '@/lib/posts' | |||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||
| import { fetchTag } from '@/lib/tags' | |||
| import { cn, dateString } from '@/lib/utils' | |||
| import { cn, dateString, originalCreatedAtString } from '@/lib/utils' | |||
| 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 (() => { | |||
| const location = useLocation () | |||
| const query = new URLSearchParams (location.search) | |||
| @@ -36,15 +51,17 @@ export default (() => { | |||
| : { data: null } | |||
| const { data, isLoading: loading } = useQuery ({ | |||
| queryKey: postsKeys.changes ({ ...(id && { id }), | |||
| queryKey: postsKeys.changes ({ ...(id && { post: id }), | |||
| ...(tagId && { tag: tagId }), | |||
| page, limit }), | |||
| queryFn: () => fetchPostChanges ({ ...(id && { id }), | |||
| queryFn: () => fetchPostChanges ({ ...(id && { post: id }), | |||
| ...(tagId && { tag: tagId }), | |||
| page, limit }) }) | |||
| const changes = data?.changes ?? [] | |||
| const changes = data?.versions ?? [] | |||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | |||
| const qc = useQueryClient () | |||
| useEffect (() => { | |||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | |||
| }, [location.search]) | |||
| @@ -65,76 +82,164 @@ export default (() => { | |||
| {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 | |||
| 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: '更新しました.' }) | |||
| }}> | |||
| 復元 | |||
| </a> | |||
| </td> | |||
| </tr>) | |||
| })} | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </>)} | |||
| @@ -289,7 +289,7 @@ export default (() => { | |||
| {results.map (row => ( | |||
| <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700"> | |||
| <td className="p-2"> | |||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> | |||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}> | |||
| <motion.div | |||
| layoutId={`page-${ row.id }`} | |||
| transition={{ type: 'spring', | |||
| @@ -304,7 +304,7 @@ export default (() => { | |||
| </PrefetchLink> | |||
| </td> | |||
| <td className="p-2 truncate"> | |||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> | |||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}> | |||
| {row.title} | |||
| </PrefetchLink> | |||
| </td> | |||
| @@ -117,9 +117,9 @@ export type NiconicoViewerHandle = { | |||
| export type Post = { | |||
| id: number | |||
| url: string | |||
| title: string | |||
| thumbnail: string | |||
| thumbnailBase: string | |||
| title: string | null | |||
| thumbnail: string | null | |||
| thumbnailBase: string | null | |||
| tags: Tag[] | |||
| viewed: boolean | |||
| related: Post[] | |||
| @@ -127,7 +127,7 @@ export type Post = { | |||
| originalCreatedBefore: string | null | |||
| createdAt: string | |||
| updatedAt: string | |||
| uploadedUser: { id: number; name: string } | null } | |||
| uploadedUser: { id: number; name: string | null } | null } | |||
| export type PostTagChange = { | |||
| post: Post | |||
| @@ -140,14 +140,13 @@ export type PostVersion = { | |||
| postId: number | |||
| versionNo: number | |||
| eventType: 'create' | 'update' | 'discard' | 'restore' | |||
| title: string | |||
| url: string | |||
| thumbnail: string | |||
| thumbnailBase: string | |||
| tags: Tag[] | |||
| parent: Post | null | |||
| originalCreatedFrom: string | null | |||
| originalCreatedBefore: string | null | |||
| 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 } | |||