| @@ -100,23 +100,16 @@ class PostsController < ApplicationController | |||||
| .first | .first | ||||
| return head :not_found unless post | return head :not_found unless post | ||||
| viewed = current_user&.viewed?(post) || false | |||||
| render json: PostRepr.base(post).merge(viewed:) | |||||
| render json: PostRepr.base(post, current_user) | |||||
| end | end | ||||
| def show | def show | ||||
| post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id]) | post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id]) | ||||
| return head :not_found unless post | return head :not_found unless post | ||||
| viewed = current_user&.viewed?(post) || false | |||||
| json = post.as_json | |||||
| json['tags'] = build_tag_tree_for(post.tags) | |||||
| json['related'] = post.related(limit: 20) | |||||
| json['viewed'] = viewed | |||||
| render json: | |||||
| render json: PostRepr.base(post, current_user) | |||||
| .merge(tags: build_tag_tree_for(post.tags), | |||||
| related: post.related(limit: 20)) | |||||
| end | end | ||||
| def create | def create | ||||
| @@ -2,15 +2,19 @@ | |||||
| module PostRepr | module PostRepr | ||||
| BASE = { include: { tags: TagRepr::BASE } }.freeze | |||||
| BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze | |||||
| module_function | module_function | ||||
| def base post | |||||
| post.as_json(BASE) | |||||
| def base post, current_user = nil | |||||
| json = post.as_json(BASE) | |||||
| return json unless current_user | |||||
| viewed = current_user.viewed?(post) | |||||
| json.merge(viewed:) | |||||
| end | end | ||||
| def many posts | |||||
| posts.map { |p| base(p) } | |||||
| def many posts, current_user = nil | |||||
| posts.map { |p| base(p, current_user) } | |||||
| end | end | ||||
| end | end | ||||
| @@ -0,0 +1,16 @@ | |||||
| # frozen_string_literal: true | |||||
| module UserRepr | |||||
| BASE = { only: [:id, :name] }.freeze | |||||
| module_function | |||||
| def base user | |||||
| user.as_json(BASE) | |||||
| end | |||||
| def many users | |||||
| users.map { |u| base(u) } | |||||
| end | |||||
| end | |||||
| @@ -1,5 +1,6 @@ | |||||
| import { useDraggable, useDroppable } from '@dnd-kit/core' | import { useDraggable, useDroppable } from '@dnd-kit/core' | ||||
| import { CSS } from '@dnd-kit/utilities' | import { CSS } from '@dnd-kit/utilities' | ||||
| import { motion } from 'framer-motion' | |||||
| import { useRef } from 'react' | import { useRef } from 'react' | ||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| @@ -14,10 +15,11 @@ type Props = { | |||||
| nestLevel: number | nestLevel: number | ||||
| pathKey: string | pathKey: string | ||||
| parentTagId?: number | parentTagId?: number | ||||
| suppressClickRef: MutableRefObject<boolean> } | |||||
| suppressClickRef: MutableRefObject<boolean> | |||||
| sp?: boolean } | |||||
| export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Props) => { | |||||
| export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: Props) => { | |||||
| const dndId = `tag-node:${ pathKey }` | const dndId = `tag-node:${ pathKey }` | ||||
| const downPosRef = useRef<{ x: number; y: number } | null> (null) | const downPosRef = useRef<{ x: number; y: number } | null> (null) | ||||
| @@ -88,6 +90,8 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Pro | |||||
| 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}> | ||||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||||
| <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> | |||||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||||
| </motion.div> | |||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -6,8 +6,8 @@ import { DndContext, | |||||
| useSensor, | useSensor, | ||||
| useSensors } from '@dnd-kit/core' | useSensors } from '@dnd-kit/core' | ||||
| import { restrictToWindowEdges } from '@dnd-kit/modifiers' | import { restrictToWindowEdges } from '@dnd-kit/modifiers' | ||||
| import { AnimatePresence, motion } from 'framer-motion' | |||||
| import { useEffect, useRef, useState } from 'react' | |||||
| import { motion } from 'framer-motion' | |||||
| import { useEffect, useMemo, useRef, useState } from 'react' | |||||
| import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow' | import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| @@ -35,28 +35,27 @@ const renderTagTree = ( | |||||
| path: string, | path: string, | ||||
| suppressClickRef: MutableRefObject<boolean>, | suppressClickRef: MutableRefObject<boolean>, | ||||
| parentTagId?: number, | parentTagId?: number, | ||||
| sp?: boolean, | |||||
| ): ReactNode[] => { | ): ReactNode[] => { | ||||
| const key = `${ path }-${ tag.id }` | const key = `${ path }-${ tag.id }` | ||||
| const self = ( | const self = ( | ||||
| <motion.li | |||||
| key={key} | |||||
| layout | |||||
| transition={{ duration: .2, ease: 'easeOut' }} | |||||
| className="mb-1"> | |||||
| <li key={key} className="mb-1"> | |||||
| <DraggableDroppableTagRow | <DraggableDroppableTagRow | ||||
| tag={tag} | tag={tag} | ||||
| nestLevel={nestLevel} | nestLevel={nestLevel} | ||||
| pathKey={key} | pathKey={key} | ||||
| parentTagId={parentTagId} | parentTagId={parentTagId} | ||||
| suppressClickRef={suppressClickRef}/> | |||||
| </motion.li>) | |||||
| suppressClickRef={suppressClickRef} | |||||
| sp={sp}/> | |||||
| </li>) | |||||
| return [ | return [ | ||||
| self, | self, | ||||
| ...((tag.children | ...((tag.children | ||||
| ?.sort ((a, b) => a.name < b.name ? -1 : 1) | ?.sort ((a, b) => a.name < b.name ? -1 : 1) | ||||
| .flatMap (child => renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id))) | |||||
| .flatMap (child => | |||||
| renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id, sp))) | |||||
| ?? [])] | ?? [])] | ||||
| } | } | ||||
| @@ -147,14 +146,32 @@ const DropSlot = ({ cat }: { cat: Category }) => { | |||||
| } | } | ||||
| type Props = { post: Post | null } | |||||
| type Props = { post: Post; sp?: boolean } | |||||
| export default (({ post, sp }: Props) => { | |||||
| sp = Boolean (sp) | |||||
| const baseTags = useMemo<TagByCategory> (() => { | |||||
| const tagsTmp = { } as TagByCategory | |||||
| for (const tag of post.tags) | |||||
| { | |||||
| if (!(tag.category in tagsTmp)) | |||||
| tagsTmp[tag.category] = [] | |||||
| tagsTmp[tag.category].push (tag) | |||||
| } | |||||
| for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[]) | |||||
| tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1) | |||||
| return tagsTmp | |||||
| }, [post]) | |||||
| export default (({ post }: Props) => { | |||||
| const [activeTagId, setActiveTagId] = useState<number | null> (null) | const [activeTagId, setActiveTagId] = useState<number | null> (null) | ||||
| const [dragging, setDragging] = useState (false) | const [dragging, setDragging] = useState (false) | ||||
| const [saving, setSaving] = useState (false) | const [saving, setSaving] = useState (false) | ||||
| const [tags, setTags] = useState ({ } as TagByCategory) | |||||
| const [tags, setTags] = useState (baseTags) | |||||
| const suppressClickRef = useRef (false) | const suppressClickRef = useRef (false) | ||||
| @@ -163,9 +180,6 @@ export default (({ post }: Props) => { | |||||
| useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })) | useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })) | ||||
| const reloadTags = async (): Promise<void> => { | const reloadTags = async (): Promise<void> => { | ||||
| if (!(post)) | |||||
| return | |||||
| setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`))) | setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`))) | ||||
| } | } | ||||
| @@ -256,23 +270,8 @@ export default (({ post }: Props) => { | |||||
| } | } | ||||
| useEffect (() => { | useEffect (() => { | ||||
| if (!(post)) | |||||
| return | |||||
| const tagsTmp = { } as TagByCategory | |||||
| for (const tag of post.tags) | |||||
| { | |||||
| if (!(tag.category in tagsTmp)) | |||||
| tagsTmp[tag.category] = [] | |||||
| tagsTmp[tag.category].push (tag) | |||||
| } | |||||
| for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[]) | |||||
| tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1) | |||||
| setTags (tagsTmp) | |||||
| }, [post]) | |||||
| setTags (baseTags) | |||||
| }, [baseTags]) | |||||
| return ( | return ( | ||||
| <SidebarComponent> | <SidebarComponent> | ||||
| @@ -305,60 +304,57 @@ export default (({ post }: Props) => { | |||||
| document.body.style.userSelect = '' | document.body.style.userSelect = '' | ||||
| }} | }} | ||||
| modifiers={[restrictToWindowEdges]}> | modifiers={[restrictToWindowEdges]}> | ||||
| <motion.div key={post?.id ?? 0} layout> | |||||
| {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( | |||||
| <motion.div layout className="my-3" key={cat}> | |||||
| <SubsectionTitle>{CATEGORY_NAMES[cat]}</SubsectionTitle> | |||||
| <motion.ul layout> | |||||
| <AnimatePresence initial={false}> | |||||
| {(tags[cat] ?? []).flatMap (tag => ( | |||||
| renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))} | |||||
| <DropSlot cat={cat}/> | |||||
| </AnimatePresence> | |||||
| </motion.ul> | |||||
| </motion.div>))} | |||||
| {post && ( | |||||
| <div> | |||||
| <SectionTitle>情報</SectionTitle> | |||||
| <ul> | |||||
| <li>Id.: {post.id}</li> | |||||
| {/* TODO: uploadedUser の取得を対応したらコメント外す */} | |||||
| {/* | |||||
| <li> | |||||
| <>耕作者: </> | |||||
| {post.uploadedUser | |||||
| ? ( | |||||
| <PrefetchLink to={`/users/${ post.uploadedUser.id }`}> | |||||
| {post.uploadedUser.name || '名もなきニジラー'} | |||||
| </PrefetchLink>) | |||||
| : 'bot操作'} | |||||
| </li> | |||||
| */} | |||||
| <li>耕作日時: {dateString (post.createdAt)}</li> | |||||
| <li> | |||||
| <>リンク: </> | |||||
| <a | |||||
| className="break-all" | |||||
| href={post.url} | |||||
| target="_blank" | |||||
| rel="noopener noreferrer nofollow"> | |||||
| {post.url} | |||||
| </a> | |||||
| </li> | |||||
| <li> | |||||
| <>オリジナルの投稿日時: </> | |||||
| {originalCreatedAtString (post.originalCreatedFrom, | |||||
| post.originalCreatedBefore)} | |||||
| </li> | |||||
| <li> | |||||
| <PrefetchLink to={`/posts/changes?id=${ post.id }`}> | |||||
| 履歴 | |||||
| </PrefetchLink> | |||||
| </li> | |||||
| </ul> | |||||
| </div>)} | |||||
| </motion.div> | |||||
| {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( | |||||
| <div className="my-3" key={cat}> | |||||
| <SubsectionTitle> | |||||
| <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}> | |||||
| {CATEGORY_NAMES[cat]} | |||||
| </motion.div> | |||||
| </SubsectionTitle> | |||||
| <ul> | |||||
| {(tags[cat] ?? []).flatMap (tag => ( | |||||
| renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))} | |||||
| <DropSlot cat={cat}/> | |||||
| </ul> | |||||
| </div>))} | |||||
| {post && ( | |||||
| <motion.div layoutId={`post-info-${ sp }`}> | |||||
| <SectionTitle>情報</SectionTitle> | |||||
| <ul> | |||||
| <li>Id.: {post.id}</li> | |||||
| <li> | |||||
| <>耕作者: </> | |||||
| {post.uploadedUser | |||||
| ? ( | |||||
| <PrefetchLink to={`/users/${ post.uploadedUser.id }`}> | |||||
| {post.uploadedUser.name || '名もなきニジラー'} | |||||
| </PrefetchLink>) | |||||
| : 'bot操作'} | |||||
| </li> | |||||
| <li>耕作日時: {dateString (post.createdAt)}</li> | |||||
| <li> | |||||
| <>リンク: </> | |||||
| <a | |||||
| className="break-all" | |||||
| href={post.url} | |||||
| target="_blank" | |||||
| rel="noopener noreferrer nofollow"> | |||||
| {post.url} | |||||
| </a> | |||||
| </li> | |||||
| <li> | |||||
| <>オリジナルの投稿日時: </> | |||||
| {originalCreatedAtString (post.originalCreatedFrom, | |||||
| post.originalCreatedBefore)} | |||||
| </li> | |||||
| <li> | |||||
| <PrefetchLink to={`/posts/changes?id=${ post.id }`}> | |||||
| 履歴 | |||||
| </PrefetchLink> | |||||
| </li> | |||||
| </ul> | |||||
| </motion.div>)} | |||||
| <DragOverlay adjustScale={false}> | <DragOverlay adjustScale={false}> | ||||
| <div className="pointer-events-none"> | <div className="pointer-events-none"> | ||||
| @@ -65,30 +65,31 @@ export default (({ posts, onClick }: Props) => { | |||||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | {CATEGORIES.flatMap (cat => cat in tags ? ( | ||||
| tags[cat].map (tag => ( | tags[cat].map (tag => ( | ||||
| <li key={tag.id} className="mb-1"> | <li key={tag.id} className="mb-1"> | ||||
| <TagLink tag={tag} prefetch onClick={onClick}/> | |||||
| <motion.div layoutId={`tag-${ tag.id }`}> | |||||
| <TagLink tag={tag} prefetch onClick={onClick}/> | |||||
| </motion.div> | |||||
| </li>))) : [])} | </li>))) : [])} | ||||
| </ul> | </ul> | ||||
| <SectionTitle>関聯</SectionTitle> | <SectionTitle>関聯</SectionTitle> | ||||
| {posts.length > 0 && ( | |||||
| <a href="#" | |||||
| onClick={ev => { | |||||
| ev.preventDefault () | |||||
| void ((async () => { | |||||
| try | |||||
| { | |||||
| const data = await apiGet<Post> ('/posts/random', | |||||
| { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), | |||||
| match: (anyFlg ? 'any' : 'all') } }) | |||||
| navigate (`/posts/${ data.id }`) | |||||
| } | |||||
| catch | |||||
| { | |||||
| ; | |||||
| } | |||||
| }) ()) | |||||
| }}> | |||||
| ランダム | |||||
| </a>)} | |||||
| <a href="#" | |||||
| onClick={ev => { | |||||
| ev.preventDefault () | |||||
| void ((async () => { | |||||
| try | |||||
| { | |||||
| const data = await apiGet<Post> ('/posts/random', | |||||
| { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), | |||||
| match: (anyFlg ? 'any' : 'all') } }) | |||||
| navigate (`/posts/${ data.id }`) | |||||
| } | |||||
| catch | |||||
| { | |||||
| ; | |||||
| } | |||||
| }) ()) | |||||
| }}> | |||||
| ランダム | |||||
| </a> | |||||
| </>) | </>) | ||||
| return ( | return ( | ||||
| @@ -96,7 +97,7 @@ export default (({ posts, onClick }: Props) => { | |||||
| <TagSearch/> | <TagSearch/> | ||||
| <div className="hidden md:block mt-4"> | <div className="hidden md:block mt-4"> | ||||
| {TagBlock} | |||||
| {posts.length > 0 && TagBlock} | |||||
| </div> | </div> | ||||
| <AnimatePresence initial={false}> | <AnimatePresence initial={false}> | ||||
| @@ -112,7 +113,7 @@ export default (({ posts, onClick }: Props) => { | |||||
| animate="visible" | animate="visible" | ||||
| exit="hidden" | exit="hidden" | ||||
| transition={{ duration: .2, ease: 'easeOut' }}> | transition={{ duration: .2, ease: 'easeOut' }}> | ||||
| {TagBlock} | |||||
| {posts.length > 0 && TagBlock} | |||||
| </motion.div>)} | </motion.div>)} | ||||
| </AnimatePresence> | </AnimatePresence> | ||||
| @@ -48,7 +48,7 @@ const getPages = ( | |||||
| } | } | ||||
| export default (({ page, totalPages, siblingCount = 2 }) => { | |||||
| export default (({ page, totalPages, siblingCount = 3 }) => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const buildTo = (p: number) => { | const buildTo = (p: number) => { | ||||
| @@ -64,30 +64,64 @@ export default (({ page, totalPages, siblingCount = 2 }) => { | |||||
| <div className="flex items-center gap-2"> | <div className="flex items-center gap-2"> | ||||
| {(page > 1) | {(page > 1) | ||||
| ? ( | ? ( | ||||
| <PrefetchLink | |||||
| className="p-2" | |||||
| to={buildTo (page - 1)} | |||||
| aria-label="前のページ"> | |||||
| < | |||||
| </PrefetchLink>) | |||||
| : <span className="p-2" aria-hidden><</span>} | |||||
| <> | |||||
| <PrefetchLink | |||||
| className="md:hidden p-2" | |||||
| to={buildTo (1)} | |||||
| aria-label="最初のページ"> | |||||
| |< | |||||
| </PrefetchLink> | |||||
| <PrefetchLink | |||||
| className="p-2" | |||||
| to={buildTo (page - 1)} | |||||
| aria-label="前のページ"> | |||||
| < | |||||
| </PrefetchLink> | |||||
| </>) | |||||
| : ( | |||||
| <> | |||||
| <span className="md:hidden p-2" aria-hidden> | |||||
| |< | |||||
| </span> | |||||
| <span className="p-2" aria-hidden> | |||||
| < | |||||
| </span> | |||||
| </>)} | |||||
| {pages.map ((p, idx) => ( | {pages.map ((p, idx) => ( | ||||
| (p === '…') | (p === '…') | ||||
| ? <span key={`dots-${ idx }`} className="p-2">…</span> | |||||
| ? <span key={`dots-${ idx }`} className="hidden md:block p-2">…</span> | |||||
| : ((p === page) | : ((p === page) | ||||
| ? <span key={p} className="font-bold p-2" aria-current="page">{p}</span> | ? <span key={p} className="font-bold p-2" aria-current="page">{p}</span> | ||||
| : <PrefetchLink key={p} className="p-2" to={buildTo (p)}>{p}</PrefetchLink>)))} | |||||
| : ( | |||||
| <PrefetchLink | |||||
| key={p} | |||||
| className="hidden md:block p-2" | |||||
| to={buildTo (p)}> | |||||
| {p} | |||||
| </PrefetchLink>))))} | |||||
| {(page < totalPages) | {(page < totalPages) | ||||
| ? ( | ? ( | ||||
| <PrefetchLink | |||||
| className="p-2" | |||||
| to={buildTo (page + 1)} | |||||
| aria-label="次のページ"> | |||||
| > | |||||
| </PrefetchLink>) | |||||
| : <span className="p-2" aria-hidden>></span>} | |||||
| <> | |||||
| <PrefetchLink | |||||
| className="p-2" | |||||
| to={buildTo (page + 1)} | |||||
| aria-label="次のページ"> | |||||
| > | |||||
| </PrefetchLink> | |||||
| <PrefetchLink | |||||
| className="md:hidden p-2" | |||||
| to={buildTo (totalPages)} | |||||
| aria-label="最後のページ"> | |||||
| >| | |||||
| </PrefetchLink> | |||||
| </>) | |||||
| : ( | |||||
| <> | |||||
| <span className="p-2" aria-hidden>></span> | |||||
| <span className="md:hidden p-2" aria-hidden>>|</span> | |||||
| </>)} | |||||
| </div> | </div> | ||||
| </nav>) | </nav>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -99,7 +99,7 @@ export default (({ user }: Props) => { | |||||
| </Helmet> | </Helmet> | ||||
| <div className="hidden md:block"> | <div className="hidden md:block"> | ||||
| <TagDetailSidebar post={post ?? null}/> | |||||
| {post && <TagDetailSidebar post={post}/>} | |||||
| </div> | </div> | ||||
| <MainArea className="relative"> | <MainArea className="relative"> | ||||
| @@ -149,7 +149,7 @@ export default (({ user }: Props) => { | |||||
| </MainArea> | </MainArea> | ||||
| <div className="md:hidden"> | <div className="md:hidden"> | ||||
| <TagDetailSidebar post={post ?? null}/> | |||||
| {post && <TagDetailSidebar post={post} sp/>} | |||||
| </div> | </div> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -41,8 +41,8 @@ export default (() => { | |||||
| const qName = query.get ('name') ?? '' | const qName = query.get ('name') ?? '' | ||||
| const qCategory = (query.get ('category') || null) as Category | null | const qCategory = (query.get ('category') || null) as Category | null | ||||
| const qPostCountGTE = Number (query.get ('post_count_gte') ?? 1) | const qPostCountGTE = Number (query.get ('post_count_gte') ?? 1) | ||||
| const qPostCountLTE = | |||||
| query.get ('post_count_lte') ? Number (query.get ('post_count_lte')) : null | |||||
| const qPostCountLTERaw = query.get ('post_count_lte') | |||||
| const qPostCountLTE = qPostCountLTERaw ? Number (qPostCountLTERaw) : null | |||||
| const qCreatedFrom = query.get ('created_from') ?? '' | const qCreatedFrom = query.get ('created_from') ?? '' | ||||
| const qCreatedTo = query.get ('created_to') ?? '' | const qCreatedTo = query.get ('created_to') ?? '' | ||||
| const qUpdatedFrom = query.get ('updated_from') ?? '' | const qUpdatedFrom = query.get ('updated_from') ?? '' | ||||
| @@ -73,7 +73,8 @@ export type Post = { | |||||
| originalCreatedFrom: string | null | originalCreatedFrom: string | null | ||||
| originalCreatedBefore: string | null | originalCreatedBefore: string | null | ||||
| createdAt: string | createdAt: string | ||||
| updatedAt: string } | |||||
| updatedAt: string | |||||
| uploadedUser: { id: number; name: string } | null } | |||||
| export type PostTagChange = { | export type PostTagChange = { | ||||
| post: Post | post: Post | ||||