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
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
+9 -5
View File
@@ -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
+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 { 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}>
<motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>
</div>)
}) satisfies FC<Props>
+44 -48
View File
@@ -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 }: 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 [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,26 +304,25 @@ 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>
<div className="my-3" key={cat}>
<SubsectionTitle>
<motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}>
{CATEGORY_NAMES[cat]}
</motion.div>
</SubsectionTitle>
<motion.ul layout>
<AnimatePresence initial={false}>
<ul>
{(tags[cat] ?? []).flatMap (tag => (
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))}
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))}
<DropSlot cat={cat}/>
</AnimatePresence>
</motion.ul>
</motion.div>))}
</ul>
</div>))}
{post && (
<div>
<motion.div layoutId={`post-info-${ sp }`}>
<SectionTitle></SectionTitle>
<ul>
<li>Id.: {post.id}</li>
{/* TODO: uploadedUser の取得を対応したらコメント外す */}
{/*
<li>
<>: </>
{post.uploadedUser
@@ -334,7 +332,6 @@ export default (({ post }: Props) => {
</PrefetchLink>)
: 'bot操作'}
</li>
*/}
<li>: {dateString (post.createdAt)}</li>
<li>
<>: </>
@@ -357,8 +354,7 @@ export default (({ post }: Props) => {
</PrefetchLink>
</li>
</ul>
</div>)}
</motion.div>
</motion.div>)}
<DragOverlay adjustScale={false}>
<div className="pointer-events-none">
+5 -4
View File
@@ -65,11 +65,12 @@ export default (({ posts, onClick }: Props) => {
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<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 ()
@@ -88,7 +89,7 @@ export default (({ posts, onClick }: Props) => {
}) ())
}}>
</a>)}
</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>
+41 -7
View File
@@ -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="md:hidden p-2"
to={buildTo (1)}
aria-label="最初のページ">
|&lt;
</PrefetchLink>
<PrefetchLink
className="p-2"
to={buildTo (page - 1)}
aria-label="前のページ">
&lt;
</PrefetchLink>)
: <span className="p-2" aria-hidden>&lt;</span>}
</PrefetchLink>
</>)
: (
<>
<span className="md:hidden p-2" aria-hidden>
|&lt;
</span>
<span className="p-2" aria-hidden>
&lt;
</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="次のページ">
&gt;
</PrefetchLink>)
: <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>
</nav>)
}) satisfies FC<Props>
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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') ?? ''
+2 -1
View File
@@ -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