| @@ -109,7 +109,7 @@ class PostsController < ApplicationController | |||||
| render json: PostRepr.base(post, current_user) | render json: PostRepr.base(post, current_user) | ||||
| .merge(tags: build_tag_tree_for(post.tags), | .merge(tags: build_tag_tree_for(post.tags), | ||||
| related: post.related(limit: 20)) | |||||
| related: PostRepr.many(post.related(limit: 20))) | |||||
| end | end | ||||
| def create | def create | ||||
| @@ -37,18 +37,26 @@ class Post < ApplicationRecord | |||||
| def parent_posts = parents | def parent_posts = parents | ||||
| def child_posts = children | |||||
| def sibling_posts | |||||
| parent_post_ids = parent_posts.order(:id).pluck(:id) | |||||
| parent_post_ids.to_h { [_1, PostImplication.where(parent_post: _1).map(&:post)] } | |||||
| end | |||||
| def as_json options = { } | def as_json options = { } | ||||
| super(options).merge({ thumbnail: thumbnail.attached? ? | |||||
| Rails.application.routes.url_helpers.rails_blob_url( | |||||
| thumbnail, only_path: false) : | |||||
| nil }) | |||||
| super(options).merge(thumbnail: thumbnail.attached? ? | |||||
| Rails.application.routes.url_helpers.rails_blob_url( | |||||
| thumbnail, only_path: false) : | |||||
| nil) | |||||
| rescue | rescue | ||||
| super(options).merge(thumbnail: nil) | super(options).merge(thumbnail: nil) | ||||
| end | end | ||||
| def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') | def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') | ||||
| def snapshot_parent_post_ids = parents.order(:parent_post_id).pulck(:parent_post_id) | |||||
| def snapshot_parent_post_ids = parents.order(:parent_post_id).pluck(:parent_post_id) | |||||
| def related limit: nil | def related limit: nil | ||||
| ids = post_similarities.order(cos: :desc) | ids = post_similarities.order(cos: :desc) | ||||
| @@ -3,7 +3,7 @@ | |||||
| module PostRepr | module PostRepr | ||||
| BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE }, | BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE }, | ||||
| methods: [:parent_posts] }.freeze | |||||
| methods: [:parent_posts, :child_posts, :sibling_posts] }.freeze | |||||
| module_function | module_function | ||||
| @@ -4,6 +4,7 @@ import PostFormTagsArea from '@/components/PostFormTagsArea' | |||||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | ||||
| import Label from '@/components/common/Label' | import Label from '@/components/common/Label' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { toast } from '@/components/ui/use-toast' | |||||
| import { apiPut } from '@/lib/api' | import { apiPut } from '@/lib/api' | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| @@ -35,20 +36,30 @@ export default (({ post, onSave }: Props) => { | |||||
| useState<string | null> (post.originalCreatedBefore) | useState<string | null> (post.originalCreatedBefore) | ||||
| const [originalCreatedFrom, setOriginalCreatedFrom] = | const [originalCreatedFrom, setOriginalCreatedFrom] = | ||||
| useState<string | null> (post.originalCreatedFrom) | useState<string | null> (post.originalCreatedFrom) | ||||
| const [title, setTitle] = useState (post.title) | |||||
| const [parentPostIds, setParentPostIds] = useState (post.parentPosts!.map (p => p.id).join (' ')) | |||||
| const [tags, setTags] = useState<string> ('') | const [tags, setTags] = useState<string> ('') | ||||
| const [title, setTitle] = useState (post.title) | |||||
| const handleSubmit = async () => { | const handleSubmit = async () => { | ||||
| const data = await apiPut<Post> ( | |||||
| `/posts/${ post.id }`, | |||||
| { title, tags, original_created_from: originalCreatedFrom, | |||||
| original_created_before: originalCreatedBefore }, | |||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| onSave ({ ...post, | |||||
| title: data.title, | |||||
| tags: data.tags, | |||||
| originalCreatedFrom: data.originalCreatedFrom, | |||||
| originalCreatedBefore: data.originalCreatedBefore } as Post) | |||||
| try | |||||
| { | |||||
| const data = await apiPut<Post> ( | |||||
| `/posts/${ post.id }`, | |||||
| { title, tags, parent_post_ids: parentPostIds, | |||||
| original_created_from: originalCreatedFrom, | |||||
| original_created_before: originalCreatedBefore }, | |||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| onSave ({ ...post, | |||||
| title: data.title, | |||||
| tags: data.tags, | |||||
| originalCreatedFrom: data.originalCreatedFrom, | |||||
| originalCreatedBefore: data.originalCreatedBefore } as Post) | |||||
| toast ({ description: '更新しました.' }) | |||||
| } | |||||
| catch | |||||
| { | |||||
| toast ({ description: '更新はできなかったよ……' }) | |||||
| } | |||||
| } | } | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -66,6 +77,16 @@ export default (({ post, onSave }: Props) => { | |||||
| onChange={ev => setTitle (ev.target.value)}/> | onChange={ev => setTitle (ev.target.value)}/> | ||||
| </div> | </div> | ||||
| {/* 親投稿 */} | |||||
| <div> | |||||
| <Label>親投稿</Label> | |||||
| <input | |||||
| type="text" | |||||
| value={parentPostIds} | |||||
| onChange={e => setParentPostIds (e.target.value)} | |||||
| className="w-full border p-2 rounded"/> | |||||
| </div> | |||||
| {/* タグ */} | {/* タグ */} | ||||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | <PostFormTagsArea tags={tags} setTags={setTags}/> | ||||
| @@ -3,6 +3,7 @@ import { useRef } from 'react' | |||||
| import { useLocation } from 'react-router-dom' | import { useLocation } from 'react-router-dom' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| import { cn } from '@/lib/utils' | |||||
| import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' | import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' | ||||
| import type { FC, MouseEvent } from 'react' | import type { FC, MouseEvent } from 'react' | ||||
| @@ -39,8 +40,10 @@ export default (({ posts, onClick }: Props) => { | |||||
| <motion.div | <motion.div | ||||
| ref={cardRef} | ref={cardRef} | ||||
| layoutId={layoutId} | layoutId={layoutId} | ||||
| className="w-full h-full overflow-hidden rounded-xl shadow | |||||
| transform-gpu will-change-transform" | |||||
| className={cn ('w-full h-full overflow-hidden rounded-xl shadow', | |||||
| 'transform-gpu will-change-transform', | |||||
| (post.childPosts ?? []).length > 0 && 'border-4 border-green-500', | |||||
| (post.parentPosts ?? []).length > 0 && 'border-4 border-yellow-500')} | |||||
| whileHover={{ scale: 1.02 }} | whileHover={{ scale: 1.02 }} | ||||
| onLayoutAnimationStart={() => { | onLayoutAnimationStart={() => { | ||||
| if (!(cardRef.current)) | if (!(cardRef.current)) | ||||
| @@ -21,7 +21,7 @@ import ServiceUnavailable from '@/pages/ServiceUnavailable' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import type { NiconicoViewerHandle, User } from '@/types' | |||||
| import type { NiconicoViewerHandle, Post, User } from '@/types' | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| @@ -108,7 +108,34 @@ export default (({ user }: Props) => { | |||||
| {post | {post | ||||
| ? ( | ? ( | ||||
| <> | <> | ||||
| {/* TODO: 親投稿リスト */} | |||||
| {(post.childPosts ?? []).length > 0 && ( | |||||
| <div className="mb-4 bg-green-200 dark:bg-green-800 text-sm p-2 rounded-md"> | |||||
| <p>この投稿には {post.childPosts!.length} 件の子投稿があります.</p> | |||||
| <PostList posts={[{ ...post, childPosts: [{ } as Post] }, | |||||
| ...post.childPosts!.map (p => ({ | |||||
| ...p, parentPosts: [{ } as Post] }))]}/> | |||||
| </div> | |||||
| )} | |||||
| {(post.parentPosts ?? []).map (pp => { | |||||
| const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`] | |||||
| if (!(siblings)) | |||||
| return | |||||
| return ( | |||||
| <div | |||||
| key={pp.id} | |||||
| className="mb-4 bg-yellow-200 dark:bg-yellow-800 text-sm p-2 rounded-md"> | |||||
| <p> | |||||
| この投稿には 1 件の親投稿{ | |||||
| siblings.length > 1 | |||||
| && `と ${ siblings.length - 1 } 件の姉妹投稿`}があります. | |||||
| </p> | |||||
| <PostList posts={[{ ...pp, childPosts: [{ } as Post] }, | |||||
| ...siblings.map (p => ({ | |||||
| ...p, parentPosts: [{ } as Post] }))]}/> | |||||
| </div>) | |||||
| })} | |||||
| {(post.thumbnail || post.thumbnailBase) && ( | {(post.thumbnail || post.thumbnailBase) && ( | ||||
| <motion.div | <motion.div | ||||
| layoutId={`page-${ id }`} | layoutId={`page-${ id }`} | ||||
| @@ -147,7 +174,6 @@ export default (({ user }: Props) => { | |||||
| (prev: any) => newPost ?? prev) | (prev: any) => newPost ?? prev) | ||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | qc.invalidateQueries ({ queryKey: postsKeys.root }) | ||||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | qc.invalidateQueries ({ queryKey: tagsKeys.root }) | ||||
| toast ({ description: '更新しました.' }) | |||||
| }}/> | }}/> | ||||
| </Tab>)} | </Tab>)} | ||||
| </TabGroup> | </TabGroup> | ||||
| @@ -95,6 +95,8 @@ export default (() => { | |||||
| <col className="w-96"/> | <col className="w-96"/> | ||||
| {/* タグ */} | {/* タグ */} | ||||
| <col className="w-[48rem]"/> | <col className="w-[48rem]"/> | ||||
| {/* 親投稿 */} | |||||
| <col className="w-[48rem]"/> | |||||
| {/* オリジナルの投稿日時 */} | {/* オリジナルの投稿日時 */} | ||||
| <col className="w-96"/> | <col className="w-96"/> | ||||
| {/* 更新日時 */} | {/* 更新日時 */} | ||||
| @@ -110,6 +112,7 @@ export default (() => { | |||||
| <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">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 text-left">オリジナルの投稿日時</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"/> | <th className="p-2"/> | ||||
| @@ -180,6 +183,28 @@ export default (() => { | |||||
| {tag.name} | {tag.name} | ||||
| </span>))))} | </span>))))} | ||||
| </td> | </td> | ||||
| <td className="p-2"> | |||||
| {change.parentPosts.map ((pp, i) => ( | |||||
| pp.type === 'added' | |||||
| ? ( | |||||
| <ins | |||||
| key={i} | |||||
| className="mr-2 text-green-600 dark:text-green-400"> | |||||
| {pp.title} | |||||
| </ins>) | |||||
| : ( | |||||
| pp.type === 'removed' | |||||
| ? ( | |||||
| <del | |||||
| key={i} | |||||
| className="mr-2 text-red-600 dark:text-red-400"> | |||||
| {pp.title} | |||||
| </del>) | |||||
| : ( | |||||
| <span key={i} className="mr-2"> | |||||
| {pp.title} | |||||
| </span>))))} | |||||
| </td> | |||||
| <td className="p-2"> | <td className="p-2"> | ||||
| {change.versionNo === 1 | {change.versionNo === 1 | ||||
| ? originalCreatedAtString (change.originalCreatedFrom.current, | ? originalCreatedAtString (change.originalCreatedFrom.current, | ||||
| @@ -225,6 +250,8 @@ export default (() => { | |||||
| .map (t => t.name) | .map (t => t.name) | ||||
| .filter (t => t.slice (0, 5) !== 'nico:') | .filter (t => t.slice (0, 5) !== 'nico:') | ||||
| .join (' '), | .join (' '), | ||||
| parent_post_ids: | |||||
| change.parentPosts.map (p => p.id).join (' '), | |||||
| original_created_from: | original_created_from: | ||||
| change.originalCreatedFrom.current, | change.originalCreatedFrom.current, | ||||
| original_created_before: | original_created_before: | ||||
| @@ -29,6 +29,7 @@ export default (({ user }: Props) => { | |||||
| const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null) | const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null) | ||||
| const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) | const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) | ||||
| const [parentPostIds, setParentPostIds] = useState ('') | |||||
| const [tags, setTags] = useState ('') | const [tags, setTags] = useState ('') | ||||
| const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) | const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) | ||||
| const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) | const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) | ||||
| @@ -46,6 +47,7 @@ export default (({ user }: Props) => { | |||||
| formData.append ('title', title) | formData.append ('title', title) | ||||
| formData.append ('url', url) | formData.append ('url', url) | ||||
| formData.append ('tags', tags) | formData.append ('tags', tags) | ||||
| formData.append ('parent_post_ids', parentPostIds) | |||||
| if (thumbnailFile) | if (thumbnailFile) | ||||
| formData.append ('thumbnail', thumbnailFile) | formData.append ('thumbnail', thumbnailFile) | ||||
| if (originalCreatedFrom) | if (originalCreatedFrom) | ||||
| @@ -177,6 +179,16 @@ export default (({ user }: Props) => { | |||||
| className="mt-2 max-h-48 rounded border"/>)} | className="mt-2 max-h-48 rounded border"/>)} | ||||
| </div> | </div> | ||||
| {/* 親投稿 */} | |||||
| <div> | |||||
| <Label>親投稿</Label> | |||||
| <input | |||||
| type="text" | |||||
| value={parentPostIds} | |||||
| onChange={e => setParentPostIds (e.target.value)} | |||||
| className="w-full border p-2 rounded"/> | |||||
| </div> | |||||
| {/* タグ */} | {/* タグ */} | ||||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | <PostFormTagsArea tags={tags} setTags={setTags}/> | ||||
| @@ -121,6 +121,9 @@ export type Post = { | |||||
| thumbnail: string | null | thumbnail: string | null | ||||
| thumbnailBase: string | null | thumbnailBase: string | null | ||||
| tags: Tag[] | tags: Tag[] | ||||
| parentPosts?: Post[] | |||||
| childPosts?: Post[] | |||||
| siblingPosts?: Record<`${ number }`, Post[]> | |||||
| viewed: boolean | viewed: boolean | ||||
| related: Post[] | related: Post[] | ||||
| originalCreatedFrom: string | null | originalCreatedFrom: string | null | ||||
| @@ -144,7 +147,11 @@ export type PostVersion = { | |||||
| url: { current: string; prev: string | null } | url: { current: string; prev: string | null } | ||||
| thumbnail: { current: string | null; prev: string | null } | thumbnail: { current: string | null; prev: string | null } | ||||
| thumbnailBase: { current: string | null; prev: string | null } | thumbnailBase: { current: string | null; prev: string | null } | ||||
| tags: { name: string; type: 'context' | 'added' | 'removed' }[] | |||||
| tags: { name: string | |||||
| type: 'context' | 'added' | 'removed' }[] | |||||
| parentPosts: { id: number | |||||
| title: string | |||||
| type: 'context' | 'added' | 'removed' }[] | |||||
| originalCreatedFrom: { current: string | null; prev: string | null } | originalCreatedFrom: { current: string | null; prev: string | null } | ||||
| originalCreatedBefore: { current: string | null; prev: string | null } | originalCreatedBefore: { current: string | null; prev: string | null } | ||||
| createdAt: string | createdAt: string | ||||