Browse Source

#61

feature/061
みてるぞ 1 week ago
parent
commit
be14ae3ee4
10 changed files with 200 additions and 151 deletions
  1. +4
    -11
      backend/app/controllers/posts_controller.rb
  2. +9
    -5
      backend/app/representations/post_repr.rb
  3. +16
    -0
      backend/app/representations/user_repr.rb
  4. +7
    -3
      frontend/src/components/DraggableDroppableTagRow.tsx
  5. +83
    -87
      frontend/src/components/TagDetailSidebar.tsx
  6. +24
    -23
      frontend/src/components/TagSidebar.tsx
  7. +51
    -17
      frontend/src/components/common/Pagination.tsx
  8. +2
    -2
      frontend/src/pages/posts/PostDetailPage.tsx
  9. +2
    -2
      frontend/src/pages/tags/TagListPage.tsx
  10. +2
    -1
      frontend/src/types.ts

+ 4
- 11
backend/app/controllers/posts_controller.rb 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
backend/app/representations/post_repr.rb 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
- 0
backend/app/representations/user_repr.rb 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

+ 7
- 3
frontend/src/components/DraggableDroppableTagRow.tsx View File

@@ -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>

+ 83
- 87
frontend/src/components/TagDetailSidebar.tsx 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, 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">


+ 24
- 23
frontend/src/components/TagSidebar.tsx View File

@@ -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>



+ 51
- 17
frontend/src/components/common/Pagination.tsx 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="p-2"
to={buildTo (page - 1)}
aria-label="前のページ">
&lt;
</PrefetchLink>)
: <span className="p-2" aria-hidden>&lt;</span>}
<>
<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="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
className="p-2"
to={buildTo (page + 1)}
aria-label="次のページ">
&gt;
</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
frontend/src/pages/posts/PostDetailPage.tsx 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
frontend/src/pages/tags/TagListPage.tsx 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
frontend/src/types.ts 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


Loading…
Cancel
Save