feat: D & D による上位タグ設定(#184) (#186)

Merge branch 'main' into feature/184

Merge branch 'main' into feature/184

Merge branch 'main' into feature/184

Merge branch 'main' into feature/184

Merge branch 'main' into feature/184

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

Merge branch 'main' into feature/184

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

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

Merge branch 'main' into feature/184

Merge branch 'main' into feature/184

#184

#184

#184

#184

#184

#184

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #186
This commit was merged in pull request #186.
This commit is contained in:
2026-01-07 03:26:05 +09:00
parent b309263df5
commit 5f306b04b8
7 changed files with 499 additions and 69 deletions
@@ -0,0 +1,27 @@
class TagChildrenController < ApplicationController
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.admin?
parent_id = params[:parent_id]
child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank?
Tag.find(parent_id).children << Tag.find(child_id) rescue nil
head :no_content
end
def destroy
return head :unauthorized unless current_user
return head :forbidden unless current_user.admin?
parent_id = params[:parent_id]
child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank?
Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil
head :no_content
end
end
@@ -39,6 +39,17 @@ class TagsController < ApplicationController
end end
def update def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.member?
tag = Tag.find(params[:id])
attrs = { name: params[:name].presence,
category: params[:category].presence }.compact
tag.update!(attrs) if attrs.present?
render json: tag
end end
def destroy def destroy
+5
View File
@@ -1,6 +1,11 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :nico_tags, path: 'tags/nico', only: [:index, :update] resources :nico_tags, path: 'tags/nico', only: [:index, :update]
scope 'tags/:parent_id/children', controller: :tag_children do
post ':child_id', action: :create
delete ':child_id', action: :destroy
end
resources :tags do resources :tags do
collection do collection do
get :autocomplete get :autocomplete
+41
View File
@@ -9,6 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9", "@fontsource-variable/noto-sans-jp": "^5.2.9",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
@@ -370,6 +372,45 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.4", "version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
+2
View File
@@ -11,6 +11,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9", "@fontsource-variable/noto-sans-jp": "^5.2.9",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
@@ -0,0 +1,93 @@
import { useDraggable, useDroppable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { useRef } from 'react'
import TagLink from '@/components/TagLink'
import { cn } from '@/lib/utils'
import type { CSSProperties, FC, MutableRefObject } from 'react'
import type { Tag } from '@/types'
type Props = {
tag: Tag
nestLevel: number
pathKey: string
parentTagId?: number
suppressClickRef: MutableRefObject<boolean> }
export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Props) => {
const dndId = `tag-node:${ pathKey }`
const downPosRef = useRef<{ x: number; y: number } | null> (null)
const armedRef = useRef (false)
const armEatNextClick = () => {
if (armedRef.current)
return
armedRef.current = true
suppressClickRef.current = true
const handler = (ev: MouseEvent) => {
ev.preventDefault ()
ev.stopPropagation ()
armedRef.current = false
suppressClickRef.current = false
}
addEventListener ('click', handler, { capture: true, once: true })
}
const { attributes,
listeners,
setNodeRef: setDragRef,
transform,
isDragging: dragging } = useDraggable ({ id: dndId,
data: { kind: 'tag',
tagId: tag.id,
parentTagId } })
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
id: dndId,
data: { kind: 'tag', tagId: tag.id } })
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
opacity: dragging ? .5 : 1 }
return (
<div
onPointerDownCapture={e => {
downPosRef.current = { x: e.clientX, y: e.clientY }
}}
onPointerMoveCapture={e => {
const p = downPosRef.current
if (!(p))
return
const dx = e.clientX - p.x
const dy = e.clientY - p.y
if (dx * dx + dy * dy >= 9)
armEatNextClick ()
}}
onPointerUpCapture={() => {
downPosRef.current = null
}}
onClickCapture={e => {
if (suppressClickRef.current)
{
e.preventDefault ()
e.stopPropagation ()
}
}}
ref={node => {
setDragRef (node)
setDropRef (node)
}}
style={style}
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
{...attributes}
{...listeners}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</div>)
}) satisfies FC<Props>
+259 -8
View File
@@ -1,15 +1,26 @@
import { DndContext,
MouseSensor,
TouchSensor,
useDroppable,
useSensor,
useSensors } from '@dnd-kit/core'
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import TagLink from '@/components/TagLink' import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import TagSearch from '@/components/TagSearch' import TagSearch from '@/components/TagSearch'
import SectionTitle from '@/components/common/SectionTitle' import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast'
import { API_BASE_URL } from '@/config'
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import type { FC, ReactNode } from 'react' import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, Tag } from '@/types' import type { Category, Post, Tag } from '@/types'
@@ -20,6 +31,8 @@ const renderTagTree = (
tag: Tag, tag: Tag,
nestLevel: number, nestLevel: number,
path: string, path: string,
suppressClickRef: MutableRefObject<boolean>,
parentTagId?: number,
): ReactNode[] => { ): ReactNode[] => {
const key = `${ path }-${ tag.id }` const key = `${ path }-${ tag.id }`
@@ -29,23 +42,235 @@ const renderTagTree = (
layout layout
transition={{ duration: .2, ease: 'easeOut' }} transition={{ duration: .2, ease: 'easeOut' }}
className="mb-1"> className="mb-1">
<TagLink tag={tag} nestLevel={nestLevel}/> <DraggableDroppableTagRow
tag={tag}
nestLevel={nestLevel}
pathKey={key}
parentTagId={parentTagId}
suppressClickRef={suppressClickRef}/>
</motion.li>) </motion.li>)
return [self, return [
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))) .flatMap (child => renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id)))
?? [])] ?? [])]
} }
const isDescendant = (
root: Tag,
targetId: number,
): boolean => {
if (!(root.children))
return false
for (const c of root.children)
{
if (c.id === targetId)
return true
if (isDescendant (c, targetId))
return true
}
return false
}
const findTag = (
byCat: TagByCategory,
id: number,
): Tag | undefined => {
const walk = (nodes: Tag[]): Tag | undefined => {
for (const t of nodes)
{
if (t.id === id)
return t
const found = t.children ? walk (t.children) : undefined
if (found)
return found
}
return undefined
}
for (const cat of Object.keys (byCat) as (keyof typeof byCat)[])
{
const found = walk (byCat[cat] ?? [])
if (found)
return found
}
return undefined
}
const buildTagByCategory = (post: Post): 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 ((a, b) => a.name < b.name ? -1 : 1)
return tagsTmp
}
const changeCategory = async (
tagId: number,
category: Category,
): Promise<void> => {
await axios.patch (
`${ API_BASE_URL }/tags/${ tagId }`,
{ category },
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
}
const DropSlot = ({ cat }: { cat: Category }) => {
const { setNodeRef, isOver: over } = useDroppable ({
id: `slot:${ cat }`,
data: { kind: 'slot', cat } })
return (
<li ref={setNodeRef} className="h-1">
{over && <div className="h-0.5 w-full rounded bg-sky-400"/>}
</li>)
}
type Props = { post: Post | null } type Props = { post: Post | null }
export default (({ post }: Props) => { export default (({ post }: Props) => {
const [dragging, setDragging] = useState (false)
const [saving, setSaving] = useState (false)
const [tags, setTags] = useState ({ } as TagByCategory) const [tags, setTags] = useState ({ } as TagByCategory)
const suppressClickRef = useRef (false)
const sensors = useSensors (
useSensor (MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }))
const reloadTags = async (): Promise<void> => {
if (!(post))
return
const res = await axios.get (
`${ API_BASE_URL }/posts/${ post.id }`,
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
const data = toCamel (res.data as any, { deep: true }) as Post
setTags (buildTagByCategory (data))
}
const onDragEnd = async (e: DragEndEvent) => {
if (saving)
return
const activeKind = e.active.data.current?.kind
if (activeKind !== 'tag')
return
const childId: number | undefined = e.active.data.current?.tagId
const fromParentId: number | undefined = e.active.data.current?.parentTagId
const overKind = e.over?.data.current?.kind
if (childId == null || !(overKind))
return
const child = findTag (tags, childId)
try
{
setSaving (true)
switch (overKind)
{
case 'tag':
{
const parentId: number | undefined = e.over?.data.current?.tagId
if (parentId == null || childId === parentId)
return
const parent = findTag (tags, parentId)
if (!(child)
|| !(parent)
|| isDescendant (child, parentId))
return
if (fromParentId != null)
{
await axios.delete (
`${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`,
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
}
await axios.post (
`${ API_BASE_URL }/tags/${ parentId }/children/${ childId }`,
{ },
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
await reloadTags ()
toast ({
title: '上位タグ対応追加',
description: `${ child?.name }》を《${ parent?.name }》の子タグに設定しました.` })
break
}
case 'slot':
{
const cat: Category | undefined = e.over?.data.current?.cat
if (!(cat) || !(child))
return
if (child.category !== cat)
await changeCategory (childId, cat)
if (fromParentId != null)
{
await axios.delete (
`${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`,
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
}
const fromParent = fromParentId == null ? null : findTag (tags, fromParentId)
await reloadTags ()
toast ({
title: '上位タグ対応解除',
description: (
fromParent
? `${ child.name }》を《${ fromParent.name }》の子タグから外しました.`
: `${ child.name }》のカテゴリを変更しました.`) })
break
}
}
}
catch
{
toast ({ title: '上位タグ対応失敗', description: '独裁者である必要があります.' })
}
finally
{
setSaving (false)
}
}
const categoryNames: Record<Category, string> = { const categoryNames: Record<Category, string> = {
deerjikist: 'ニジラー', deerjikist: 'ニジラー',
meme: '原作・ネタ元・ミーム等', meme: '原作・ネタ元・ミーム等',
@@ -77,14 +302,39 @@ export default (({ post }: Props) => {
return ( return (
<SidebarComponent> <SidebarComponent>
<TagSearch/> <TagSearch/>
<DndContext
sensors={sensors}
onDragStart={() => {
setDragging (true)
suppressClickRef.current = true
document.body.style.userSelect = 'none'
getSelection?.()?.removeAllRanges?.()
addEventListener ('click', e => {
e.preventDefault ()
e.stopPropagation ()
suppressClickRef.current = false
}, { capture: true, once: true })
}}
onDragCancel={() => {
setDragging (false)
document.body.style.userSelect = ''
suppressClickRef.current = false
}}
onDragEnd={e => {
setDragging (false)
onDragEnd (e)
document.body.style.userSelect = ''
}}>
<motion.div key={post?.id ?? 0} layout> <motion.div key={post?.id ?? 0} layout>
{CATEGORIES.map ((cat: Category) => cat in tags && ( {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
<motion.div layout className="my-3" key={cat}> <motion.div layout className="my-3" key={cat}>
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
<motion.ul layout> <motion.ul layout>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))} {(tags[cat] ?? []).flatMap (tag => (
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))}
<DropSlot cat={cat}/>
</AnimatePresence> </AnimatePresence>
</motion.ul> </motion.ul>
</motion.div>))} </motion.div>))}
@@ -135,5 +385,6 @@ export default (({ post }: Props) => {
</ul> </ul>
</div>)} </div>)}
</motion.div> </motion.div>
</DndContext>
</SidebarComponent>) </SidebarComponent>)
}) satisfies FC<Props> }) satisfies FC<Props>