From 48f823a7c895d8908c01d89e1721d42c290eb755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sat, 18 Apr 2026 05:43:33 +0900 Subject: [PATCH] =?UTF-8?q?=E5=B1=A5=E6=AD=B4=E7=94=BB=E9=9D=A2=E5=A4=89?= =?UTF-8?q?=E6=9B=B4=EF=BC=88#308=EF=BC=89=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge branch 'main' into feature/308 #308 #308 #308 #308 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/315 --- .../controllers/post_versions_controller.rb | 119 ++++++++ backend/app/controllers/posts_controller.rb | 8 +- backend/config/routes.rb | 1 + backend/spec/requests/posts_spec.rb | 212 ++++++++++++++ .../components/DraggableDroppableTagRow.tsx | 4 +- frontend/src/components/PostEditForm.tsx | 2 +- frontend/src/components/TagDetailSidebar.tsx | 8 +- frontend/src/lib/posts.ts | 14 +- frontend/src/lib/queryKeys.ts | 2 +- frontend/src/pages/posts/PostDetailPage.tsx | 4 +- frontend/src/pages/posts/PostHistoryPage.tsx | 262 +++++++++++++----- frontend/src/pages/posts/PostSearchPage.tsx | 4 +- frontend/src/types.ts | 22 +- 13 files changed, 563 insertions(+), 99 deletions(-) create mode 100644 backend/app/controllers/post_versions_controller.rb diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb new file mode 100644 index 0000000..04032e3 --- /dev/null +++ b/backend/app/controllers/post_versions_controller.rb @@ -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 diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index fda77ed..26ca581 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -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) @@ -204,7 +204,7 @@ class PostsController < ApplicationController pts = pts.where(post_id: id) if id.present? pts = pts.where(tag_id:) if tag_id.present? pts = pts.includes(:post, :created_user, :deleted_user, - tag: { tag_name: :wiki_page }) + tag: [:materials, { tag_name: :wiki_page }]) events = [] pts.each do |pt| diff --git a/backend/config/routes.rb b/backend/config/routes.rb index fc56aa4..edd978f 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -49,6 +49,7 @@ Rails.application.routes.draw do collection do get :random get :changes + get :versions, to: 'post_versions#index' end member do diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 1295165..605fd8d 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -756,6 +756,218 @@ RSpec.describe 'Posts API', type: :request do 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 let(:user) { create(:user) } diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index 6660f7e..55bc4d4 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -90,7 +90,9 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} {...attributes} {...listeners}> - + ) diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index d4421e8..8c3411b 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -62,7 +62,7 @@ export default (({ post, onSave }: Props) => { setTitle (ev.target.value)}/> diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index c02eca9..e195619 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -313,7 +313,9 @@ export default (({ post, sp }: Props) => { {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
- + {CATEGORY_NAMES[cat]} @@ -325,7 +327,9 @@ export default (({ post, sp }: Props) => {
))} {post && ( - + 情報
  • Id.: {post.id}
  • diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 7ee14c0..57907dc 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -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 => 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 => { diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 65a8be5..610c847 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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 = { diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 51d9b15..50a19d9 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -96,7 +96,7 @@ export default (({ user }: Props) => {
    {(post?.thumbnail || post?.thumbnailBase) && ( - )} + )} {post && {`${ post.title || post.url } | ${ SITE_TITLE }`}} @@ -116,7 +116,7 @@ export default (({ user }: Props) => { initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: .2, ease: 'easeOut' }}> - {post.title diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index 4652441..fb6b27e 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -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) && ( + <> + + {diff.prev} + + {diff.current &&
    } + )} + {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,171 @@ export default (() => { {loading ? 'Loading...' : ( <> - - - - - - - - - - {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 ( - - {withPost && ( - )} - - - ) - })} - -
    投稿変更日時
    - - - {change.post.title - - - - {change.tag - ? - : '(マスタ削除済のタグ) '} - {`を${ change.changeType === 'add' ? '記載' : '消除' }`} - - {change.user - ? ( - - {change.user.name} - ) - : 'bot 操作'} -
    - {dateString (change.timestamp)} -
    +
    + + + {/* 投稿 */} + + {/* 版 */} + + {/* タイトル */} + + {/* URL */} + + {/* タグ */} + + {/* オリジナルの投稿日時 */} + + {/* 更新日時 */} + + {/* (差戻ボタン) */} + + + + + + + + + + + + + + + + + {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 ( + + {withPost && ( + )} + + + + + + + + ) + })} + +
    投稿タイトルURLタグオリジナルの投稿日時更新日時 +
    + + + {change.title.current + + + {change.postId}.{change.versionNo}{renderDiff (change.title)}{renderDiff (change.url)} + {change.tags.map ((tag, i) => ( + tag.type === 'added' + ? ( + + {tag.name} + ) + : ( + tag.type === 'removed' + ? ( + + {tag.name} + ) + : ( + + {tag.name} + ))))} + + {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) })} + + {change.createdByUser + ? ( + + {change.createdByUser.name + || `名もなきニジラー(#${ change.createdByUser.id })`} + ) + : 'bot 操作'} +
    + {dateString (change.createdAt)} +
    + { + 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: '差戻に失敗……' }) + } + }}> + 復元 + +
    +
    )} diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 73071cc..419c134 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -289,7 +289,7 @@ export default (() => { {results.map (row => ( - + { - + {row.title} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c453a10..12f8838 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 @@ -136,6 +136,20 @@ export type PostTagChange = { changeType: 'add' | 'remove' 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 = { component: ReactNode visible: boolean }