タグ一覧ページの作成(#61) (#298)

#61

#61

Merge remote-tracking branch 'origin/main' into feature/061

#61

#61

#61

#61

#61

#61

#61

#61

#61

#61

日づけ不詳の表示修正

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #298
This commit was merged in pull request #298.
This commit is contained in:
2026-03-21 19:58:02 +09:00
parent 8cf7107445
commit ee93ff8ea0
26 changed files with 1135 additions and 283 deletions
@@ -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>
+31
View File
@@ -0,0 +1,31 @@
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
export default <T extends string,>({ by, label, currentOrder, defaultDirection }: {
by: T
label: string
currentOrder: `${ T }:${ 'asc' | 'desc' }`
defaultDirection: Record<T, 'asc' | 'desc'> }) => {
const [fld, dir] = currentOrder.split (':')
const location = useLocation ()
const qs = new URLSearchParams (location.search)
const nextDir =
(by === fld)
? (dir === 'asc' ? 'desc' : 'asc')
: (defaultDirection[by] || 'desc')
qs.set ('order', `${ by }:${ nextDir }`)
qs.set ('page', '1')
return (
<PrefetchLink
className="text-inherit visited:text-inherit hover:text-inherit"
to={`${ location.pathname }?${ qs.toString () }`}>
<span className="font-bold">
{label}
{by === fld && (dir === 'asc' ? ' ▲' : ' ▼')}
</span>
</PrefetchLink>)
}
+89 -96
View File
@@ -6,8 +6,9 @@ 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 { useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useRef, useState } from 'react'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import PrefetchLink from '@/components/PrefetchLink'
@@ -17,8 +18,9 @@ import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast'
import { CATEGORIES } from '@/consts'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core'
@@ -35,28 +37,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 +148,34 @@ 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 qc = useQueryClient ()
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,10 +184,9 @@ 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 }`)))
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
}
const onDragEnd = async (e: DragEndEvent) => {
@@ -255,33 +275,9 @@ export default (({ post }: Props) => {
}
}
const categoryNames: Record<Category, string> = {
deerjikist: 'ニジラー',
meme: '原作・ネタ元・ミーム等',
character: 'キャラクター',
general: '一般',
material: '素材',
meta: 'メタタグ',
nico: 'ニコニコタグ' }
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>
@@ -314,60 +310,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>{categoryNames[cat]}</SubsectionTitle>
{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>
<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>
<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">
+28 -31
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,23 +97,19 @@ export default (({ posts, onClick }: Props) => {
<TagSearch/>
<div className="hidden md:block mt-4">
{TagBlock}
{posts.length > 0 && TagBlock}
</div>
<AnimatePresence initial={false}>
{tagsVsbl && (
<motion.div
key="sptags"
className="md:hidden mt-4"
variants={{ hidden: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
visible: { clipPath: 'inset(0 0 0% 0)',
height: 'auto'} }}
initial="hidden"
animate="visible"
exit="hidden"
className="md:hidden overflow-hidden"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: .2, ease: 'easeOut' }}>
{TagBlock}
{posts.length > 0 && TagBlock}
</motion.div>)}
</AnimatePresence>
+1 -1
View File
@@ -74,7 +74,7 @@ export default (({ user }: Props) => {
{ name: '履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'タグ一覧', to: '/tags', visible: false },
{ name: 'タグ一覧', to: '/tags', visible: true },
{ name: '別名タグ', to: '/tags/aliases', visible: false },
{ name: '上位タグ', to: '/tags/implications', visible: false },
{ name: 'ニコニコ連携', to: '/tags/nico' },
+54 -8
View File
@@ -48,7 +48,7 @@ const getPages = (
}
export default (({ page, totalPages, siblingCount = 4 }) => {
export default (({ page, totalPages, siblingCount = 3 }) => {
const location = useLocation ()
const buildTo = (p: number) => {
@@ -63,19 +63,65 @@ export default (({ page, totalPages, siblingCount = 4 }) => {
<nav className="mt-4 flex justify-center" aria-label="Pagination">
<div className="flex items-center gap-2">
{(page > 1)
? <PrefetchLink to={buildTo (page - 1)} aria-label="前のページ">&lt;</PrefetchLink>
: <span 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 }`}></span>
? <span key={`dots-${ idx }`} className="hidden md:block p-2"></span>
: ((p === page)
? <span key={p} className="font-bold" aria-current="page">{p}</span>
: <PrefetchLink key={p} to={buildTo (p)}>{p}</PrefetchLink>)))}
? <span key={p} className="font-bold p-2" aria-current="page">{p}</span>
: (
<PrefetchLink
key={p}
className="hidden md:block p-2"
to={buildTo (p)}>
{p}
</PrefetchLink>))))}
{(page < totalPages)
? <PrefetchLink to={buildTo (page + 1)} aria-label="次のページ">&gt;</PrefetchLink>
: <span 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>