This commit is contained in:
2025-12-21 04:29:01 +09:00
parent 573fec2ce1
commit 4c7c41c991
3 changed files with 136 additions and 176 deletions
@@ -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
@@ -85,7 +85,7 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Pro
setDropRef (node) setDropRef (node)
}} }}
style={style} style={style}
className={cn ('rounded select-none touch-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}/> <TagLink tag={tag} nestLevel={nestLevel}/>
+94 -145
View File
@@ -1,9 +1,11 @@
import { DndContext, import { DndContext,
PointerSensor, MouseSensor,
TouchSensor,
useDroppable, useDroppable,
useSensor, useSensor,
useSensors } from '@dnd-kit/core' useSensors } from '@dnd-kit/core'
import axios from 'axios' import axios from 'axios'
import toCamel from 'camelcase-keys'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@@ -56,80 +58,6 @@ const renderTagTree = (
} }
const removeEverywhere = (
list: Tag[],
tagId: number,
): { next: Tag[]; picked?: Tag } => {
let picked: Tag | undefined
const walk = (nodes: Tag[]): Tag[] => (
nodes
.map (t => {
const children = t.children ? walk (t.children) : undefined
return children ? { ...t, children } : t
})
.filter (t => {
if (t.id === tagId)
{
picked = picked ?? t
return false
}
return true
}))
return { next: walk (list), picked }
}
const addAsChild = (
list: Tag[],
parentId: number,
picked: Tag,
): Tag[] => {
const walk = (nodes: Tag[]): Tag[] => (
nodes.map (t => {
if (t.id !== parentId)
return t.children ? { ...t, children: walk (t.children) } : t
const cur = t.children ?? []
const exists = cur.some (c => c.id === picked.id)
const children = exists ? cur : [...cur, { ...picked, children: picked.children ?? [] }]
return { ...t, children }
}))
return walk (list)
}
const attachChildOptimistic = (
prev: TagByCategory,
parentId: number,
childId: number,
): TagByCategory => {
const next: TagByCategory = { ...prev }
let picked: Tag | undefined
for (const cat of Object.keys (next) as (keyof typeof next)[])
{
const r = removeEverywhere (next[cat], childId)
next[cat] = r.next
picked = picked ?? r.picked
}
if (!(picked))
return prev
for (const cat of Object.keys (next) as (keyof typeof next)[])
{
next[cat] =
addAsChild (next[cat], parentId, picked)
.sort ((a: Tag, b: Tag) => a.name < b.name ? -1 : 1)
}
return next
}
const isDescendant = ( const isDescendant = (
root: Tag, root: Tag,
targetId: number, targetId: number,
@@ -178,29 +106,32 @@ const findTag = (
} }
const detachEdge = ( const buildTagByCategory = (post: Post): TagByCategory => {
nodes: Tag[], const tagsTmp = { } as TagByCategory
parentId: number,
childId: number, for (const tag of post.tags)
): Tag[] => nodes.map (t => {
if (t.id === parentId)
{ {
const children = (t.children ?? []).filter (c => c.id !== childId) if (!(tag.category in tagsTmp))
return { ...t, children } 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
} }
return t.children ? { ...t, children: detachEdge (t.children, parentId, childId) } : t
})
const insertRootAt = ( const changeCategory = async (
roots: Tag[], tagId: number,
index: number, category: Category,
tag: Tag, ): Promise<void> => {
): Tag[] => { await axios.patch (
const without = roots.filter (t => t.id !== tag.id) `${ API_BASE_URL }/tags/${ tagId }`,
const next = without.slice () { category },
next.splice (Math.min (Math.max (index, 0), next.length), 0, tag) { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
return next
} }
@@ -220,14 +151,32 @@ 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 suppressClickRef = useRef (false)
const sensors = useSensors ( const sensors = useSensors (
useSensor (PointerSensor, { activationConstraint: { distance: 6 } })) 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) => { const onDragEnd = async (e: DragEndEvent) => {
if (saving)
return
const activeKind = e.active.data.current?.kind const activeKind = e.active.data.current?.kind
if (activeKind !== 'tag') if (activeKind !== 'tag')
return return
@@ -240,15 +189,27 @@ export default (({ post }: Props) => {
if (childId == null || !(overKind)) if (childId == null || !(overKind))
return return
const child = findTag (tags, childId)
try
{
setSaving (true)
switch (overKind) switch (overKind)
{ {
case 'tag': case 'tag':
{
const parentId: number | undefined = e.over?.data.current?.tagId const parentId: number | undefined = e.over?.data.current?.tagId
if (parentId == null || childId === parentId) if (parentId == null || childId === parentId)
return return
try const parent = findTag (tags, parentId)
{
if (!(child)
|| !(parent)
|| isDescendant (child, parentId))
return
if (fromParentId != null) if (fromParentId != null)
{ {
await axios.delete ( await axios.delete (
@@ -260,68 +221,54 @@ export default (({ post }: Props) => {
`${ API_BASE_URL }/tags/${ parentId }/children/${ childId }`, `${ API_BASE_URL }/tags/${ parentId }/children/${ childId }`,
{ }, { },
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
}
catch
{
toast ({ description: '管理者権限が必要です.' })
return await reloadTags ()
} toast ({
title: '上位タグ対応追加',
setTags (prev => { description: `${ child?.name }》を《${ parent?.name }》の子タグに設定しました.` })
const child = findTag (prev, childId)
const parent = findTag (prev, parentId)
if (!(child) || !(parent) || isDescendant (child, parentId))
return prev
toast ({ description: `${ child.name }》を《${ parent.name }》の子タグに設定しました.` })
return attachChildOptimistic (prev, parentId, childId)
})
break break
}
case 'slot': case 'slot':
{
const cat: Category | undefined = e.over?.data.current?.cat const cat: Category | undefined = e.over?.data.current?.cat
if (!(cat) || !(child))
if (fromParentId == null
|| !(cat)
|| cat !== findTag (tags, childId)?.category)
return return
try if (child.category !== cat)
await changeCategory (childId, cat)
if (fromParentId != null)
{ {
await axios.delete ( await axios.delete (
`${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`, `${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`,
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
} }
catch
{
toast ({ description: '管理者権限が必要です.' })
return const fromParent = fromParentId == null ? null : findTag (tags, fromParentId)
}
setTags (prev => { await reloadTags ()
const child = findTag (prev, childId) toast ({
if (!(child)) title: '上位タグ対応解除',
return prev description: (
fromParent
const next: TagByCategory = { ...prev } ? `${ child.name }》を《${ fromParent.name }》の子タグから外しました.`
: `${ child.name }》のカテゴリを変更しました.`) })
if (fromParentId != null)
next[cat] = detachEdge (next[cat], fromParentId, childId) as any
next[cat] = insertRootAt (next[cat], 0, child)
next[cat].sort ((a: Tag, b: Tag) => a.name < b.name ? -1 : 1)
return next
})
break break
} }
} }
}
catch
{
toast ({ title: '上位タグ対応失敗', description: '独裁者である必要があります.' })
}
finally
{
setSaving (false)
}
}
const categoryNames: Record<Category, string> = { const categoryNames: Record<Category, string> = {
deerjikist: 'ニジラー', deerjikist: 'ニジラー',
@@ -357,6 +304,7 @@ export default (({ post }: Props) => {
<DndContext <DndContext
sensors={sensors} sensors={sensors}
onDragStart={() => { onDragStart={() => {
setDragging (true)
suppressClickRef.current = true suppressClickRef.current = true
document.body.style.userSelect = 'none' document.body.style.userSelect = 'none'
getSelection?.()?.removeAllRanges?.() getSelection?.()?.removeAllRanges?.()
@@ -367,23 +315,24 @@ export default (({ post }: Props) => {
}, { capture: true, once: true }) }, { capture: true, once: true })
}} }}
onDragCancel={() => { onDragCancel={() => {
setDragging (false)
document.body.style.userSelect = '' document.body.style.userSelect = ''
suppressClickRef.current = false
}} }}
onDragEnd={e => { onDragEnd={e => {
setDragging (false)
onDragEnd (e) onDragEnd (e)
document.body.style.userSelect = '' 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 => ( {(tags[cat] ?? []).flatMap (tag => (
<> renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))}
{renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)}
</>))}
<DropSlot cat={cat}/> <DropSlot cat={cat}/>
</AnimatePresence> </AnimatePresence>
</motion.ul> </motion.ul>