| @@ -100,23 +100,16 @@ class PostsController < ApplicationController | |||
| .first | |||
| 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 | |||
| def show | |||
| post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id]) | |||
| 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 | |||
| def create | |||
| @@ -2,15 +2,19 @@ | |||
| module PostRepr | |||
| BASE = { include: { tags: TagRepr::BASE } }.freeze | |||
| BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze | |||
| 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 | |||
| def many posts | |||
| posts.map { |p| base(p) } | |||
| def many posts, current_user = nil | |||
| posts.map { |p| base(p, current_user) } | |||
| 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 { CSS } from '@dnd-kit/utilities' | |||
| import { motion } from 'framer-motion' | |||
| import { useRef } from 'react' | |||
| import TagLink from '@/components/TagLink' | |||
| @@ -14,10 +15,11 @@ type Props = { | |||
| nestLevel: number | |||
| pathKey: string | |||
| 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 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')} | |||
| {...attributes} | |||
| {...listeners}> | |||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||
| <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> | |||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||
| </motion.div> | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| @@ -6,8 +6,8 @@ import { DndContext, | |||
| useSensor, | |||
| useSensors } from '@dnd-kit/core' | |||
| 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 PrefetchLink from '@/components/PrefetchLink' | |||
| @@ -35,28 +35,27 @@ const renderTagTree = ( | |||
| path: string, | |||
| suppressClickRef: MutableRefObject<boolean>, | |||
| parentTagId?: number, | |||
| sp?: boolean, | |||
| ): ReactNode[] => { | |||
| const key = `${ path }-${ tag.id }` | |||
| const self = ( | |||
| <motion.li | |||
| key={key} | |||
| layout | |||
| transition={{ duration: .2, ease: 'easeOut' }} | |||
| className="mb-1"> | |||
| <li key={key} className="mb-1"> | |||
| <DraggableDroppableTagRow | |||
| tag={tag} | |||
| nestLevel={nestLevel} | |||
| pathKey={key} | |||
| parentTagId={parentTagId} | |||
| suppressClickRef={suppressClickRef}/> | |||
| </motion.li>) | |||
| suppressClickRef={suppressClickRef} | |||
| sp={sp}/> | |||
| </li>) | |||
| return [ | |||
| self, | |||
| ...((tag.children | |||
| ?.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 [dragging, setDragging] = useState (false) | |||
| const [saving, setSaving] = useState (false) | |||
| const [tags, setTags] = useState ({ } as TagByCategory) | |||
| const [tags, setTags] = useState (baseTags) | |||
| const suppressClickRef = useRef (false) | |||
| @@ -163,9 +180,6 @@ export default (({ post }: Props) => { | |||
| useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })) | |||
| const reloadTags = async (): Promise<void> => { | |||
| if (!(post)) | |||
| return | |||
| setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`))) | |||
| } | |||
| @@ -256,23 +270,8 @@ export default (({ post }: Props) => { | |||
| } | |||
| 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 ( | |||
| <SidebarComponent> | |||
| @@ -305,60 +304,57 @@ export default (({ post }: Props) => { | |||
| document.body.style.userSelect = '' | |||
| }} | |||
| 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}> | |||
| <div className="pointer-events-none"> | |||
| @@ -65,30 +65,31 @@ export default (({ posts, onClick }: Props) => { | |||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | |||
| tags[cat].map (tag => ( | |||
| <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>))) : [])} | |||
| </ul> | |||
| <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 ( | |||
| @@ -96,7 +97,7 @@ export default (({ posts, onClick }: Props) => { | |||
| <TagSearch/> | |||
| <div className="hidden md:block mt-4"> | |||
| {TagBlock} | |||
| {posts.length > 0 && TagBlock} | |||
| </div> | |||
| <AnimatePresence initial={false}> | |||
| @@ -112,7 +113,7 @@ export default (({ posts, onClick }: Props) => { | |||
| animate="visible" | |||
| exit="hidden" | |||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||
| {TagBlock} | |||
| {posts.length > 0 && TagBlock} | |||
| </motion.div>)} | |||
| </AnimatePresence> | |||
| @@ -48,7 +48,7 @@ const getPages = ( | |||
| } | |||
| export default (({ page, totalPages, siblingCount = 2 }) => { | |||
| export default (({ page, totalPages, siblingCount = 3 }) => { | |||
| const location = useLocation () | |||
| const buildTo = (p: number) => { | |||
| @@ -64,30 +64,64 @@ export default (({ page, totalPages, siblingCount = 2 }) => { | |||
| <div className="flex items-center gap-2"> | |||
| {(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) => ( | |||
| (p === '…') | |||
| ? <span key={`dots-${ idx }`} className="p-2">…</span> | |||
| ? <span key={`dots-${ idx }`} className="hidden md:block p-2">…</span> | |||
| : ((p === page) | |||
| ? <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) | |||
| ? ( | |||
| <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> | |||
| </nav>) | |||
| }) satisfies FC<Props> | |||
| @@ -99,7 +99,7 @@ export default (({ user }: Props) => { | |||
| </Helmet> | |||
| <div className="hidden md:block"> | |||
| <TagDetailSidebar post={post ?? null}/> | |||
| {post && <TagDetailSidebar post={post}/>} | |||
| </div> | |||
| <MainArea className="relative"> | |||
| @@ -149,7 +149,7 @@ export default (({ user }: Props) => { | |||
| </MainArea> | |||
| <div className="md:hidden"> | |||
| <TagDetailSidebar post={post ?? null}/> | |||
| {post && <TagDetailSidebar post={post} sp/>} | |||
| </div> | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| @@ -41,8 +41,8 @@ export default (() => { | |||
| const qName = query.get ('name') ?? '' | |||
| const qCategory = (query.get ('category') || null) as Category | null | |||
| 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 qCreatedTo = query.get ('created_to') ?? '' | |||
| const qUpdatedFrom = query.get ('updated_from') ?? '' | |||
| @@ -73,7 +73,8 @@ export type Post = { | |||
| originalCreatedFrom: string | null | |||
| originalCreatedBefore: string | null | |||
| createdAt: string | |||
| updatedAt: string } | |||
| updatedAt: string | |||
| uploadedUser: { id: number; name: string } | null } | |||
| export type PostTagChange = { | |||
| post: Post | |||