This commit is contained in:
2026-03-15 15:23:07 +09:00
parent 5581d6e1cc
commit be14ae3ee4
10 changed files with 199 additions and 150 deletions
+4 -11
View File
@@ -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, current_user)
render json: PostRepr.base(post).merge(viewed:)
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 render json: PostRepr.base(post, current_user)
.merge(tags: build_tag_tree_for(post.tags),
json = post.as_json related: post.related(limit: 20))
json['tags'] = build_tag_tree_for(post.tags)
json['related'] = post.related(limit: 20)
json['viewed'] = viewed
render json:
end end
def create def create
+9 -5
View File
@@ -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 def base post, current_user = nil
post.as_json(BASE) json = post.as_json(BASE)
return json unless current_user
viewed = current_user.viewed?(post)
json.merge(viewed:)
end end
def many posts def many posts, current_user = nil
posts.map { |p| base(p) } posts.map { |p| base(p, current_user) }
end end
end end
+16
View File
@@ -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>
+82 -86
View File
@@ -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 { motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react' 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 <li key={key} className="mb-1">
key={key}
layout
transition={{ duration: .2, ease: 'easeOut' }}
className="mb-1">
<DraggableDroppableTagRow <DraggableDroppableTagRow
tag={tag} tag={tag}
nestLevel={nestLevel} nestLevel={nestLevel}
pathKey={key} pathKey={key}
parentTagId={parentTagId} parentTagId={parentTagId}
suppressClickRef={suppressClickRef}/> suppressClickRef={suppressClickRef}
</motion.li>) 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 }: Props) => { 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])
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)) setTags (baseTags)
return }, [baseTags])
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])
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) && (
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( <div className="my-3" key={cat}>
<motion.div layout className="my-3" key={cat}> <SubsectionTitle>
<SubsectionTitle>{CATEGORY_NAMES[cat]}</SubsectionTitle> <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}>
{CATEGORY_NAMES[cat]}
</motion.div>
</SubsectionTitle>
<motion.ul layout> <ul>
<AnimatePresence initial={false}> {(tags[cat] ?? []).flatMap (tag => (
{(tags[cat] ?? []).flatMap (tag => ( renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))}
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))} <DropSlot cat={cat}/>
<DropSlot cat={cat}/> </ul>
</AnimatePresence> </div>))}
</motion.ul> {post && (
</motion.div>))} <motion.div layoutId={`post-info-${ sp }`}>
{post && ( <SectionTitle></SectionTitle>
<div> <ul>
<SectionTitle></SectionTitle> <li>Id.: {post.id}</li>
<ul> <li>
<li>Id.: {post.id}</li> <>: </>
{/* TODO: uploadedUser の取得を対応したらコメント外す */} {post.uploadedUser
{/* ? (
<li> <PrefetchLink to={`/users/${ post.uploadedUser.id }`}>
<>耕作者: </> {post.uploadedUser.name || '名もなきニジラー'}
{post.uploadedUser </PrefetchLink>)
? ( : 'bot操作'}
<PrefetchLink to={`/users/${ post.uploadedUser.id }`}> </li>
{post.uploadedUser.name || '名もなきニジラー'} <li>: {dateString (post.createdAt)}</li>
</PrefetchLink>) <li>
: 'bot操作'} <>: </>
</li> <a
*/} className="break-all"
<li>: {dateString (post.createdAt)}</li> href={post.url}
<li> target="_blank"
<>: </> rel="noopener noreferrer nofollow">
<a {post.url}
className="break-all" </a>
href={post.url} </li>
target="_blank" <li>
rel="noopener noreferrer nofollow"> <>稿: </>
{post.url} {originalCreatedAtString (post.originalCreatedFrom,
</a> post.originalCreatedBefore)}
</li> </li>
<li> <li>
<>稿: </> <PrefetchLink to={`/posts/changes?id=${ post.id }`}>
{originalCreatedAtString (post.originalCreatedFrom,
post.originalCreatedBefore)} </PrefetchLink>
</li> </li>
<li> </ul>
<PrefetchLink to={`/posts/changes?id=${ post.id }`}> </motion.div>)}
</PrefetchLink>
</li>
</ul>
</div>)}
</motion.div>
<DragOverlay adjustScale={false}> <DragOverlay adjustScale={false}>
<div className="pointer-events-none"> <div className="pointer-events-none">
+24 -23
View File
@@ -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="#"
<a href="#" onClick={ev => {
onClick={ev => { ev.preventDefault ()
ev.preventDefault () void ((async () => {
void ((async () => { try
try {
{ const data = await apiGet<Post> ('/posts/random',
const data = await apiGet<Post> ('/posts/random', { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), match: (anyFlg ? 'any' : 'all') } })
match: (anyFlg ? 'any' : 'all') } }) navigate (`/posts/${ data.id }`)
navigate (`/posts/${ data.id }`) }
} catch
catch {
{ ;
; }
} }) ())
}) ()) }}>
}}>
</a>
</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>
+51 -17
View File
@@ -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" <PrefetchLink
to={buildTo (page - 1)} className="md:hidden p-2"
aria-label="前のページ"> to={buildTo (1)}
&lt; aria-label="最初のページ">
</PrefetchLink>) |&lt;
: <span className="p-2" aria-hidden>&lt;</span>} </PrefetchLink>
<PrefetchLink
className="p-2"
to={buildTo (page - 1)}
aria-label="前のページ">
&lt;
</PrefetchLink>
</>)
: (
<>
<span className="md:hidden p-2" aria-hidden>
|&lt;
</span>
<span className="p-2" aria-hidden>
&lt;
</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" <PrefetchLink
to={buildTo (page + 1)} className="p-2"
aria-label="次のページ"> to={buildTo (page + 1)}
&gt; aria-label="次のページ">
</PrefetchLink>) &gt;
: <span className="p-2" aria-hidden>&gt;</span>} </PrefetchLink>
<PrefetchLink
className="md:hidden p-2"
to={buildTo (totalPages)}
aria-label="最後のページ">
&gt;|
</PrefetchLink>
</>)
: (
<>
<span className="p-2" aria-hidden>&gt;</span>
<span className="md:hidden p-2" aria-hidden>&gt;|</span>
</>)}
</div> </div>
</nav>) </nav>)
}) satisfies FC<Props> }) satisfies FC<Props>
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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 = const qPostCountLTERaw = query.get ('post_count_lte')
query.get ('post_count_lte') ? Number (query.get ('post_count_lte')) : null 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') ?? ''
+2 -1
View File
@@ -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