|
|
|
@@ -1,9 +1,11 @@
|
|
|
|
|
import { DndContext,
|
|
|
|
|
PointerSensor,
|
|
|
|
|
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 { 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 = (
|
|
|
|
|
root: Tag,
|
|
|
|
|
targetId: number,
|
|
|
|
@@ -178,29 +106,32 @@ const findTag = (
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const detachEdge = (
|
|
|
|
|
nodes: Tag[],
|
|
|
|
|
parentId: number,
|
|
|
|
|
childId: number,
|
|
|
|
|
): Tag[] => nodes.map (t => {
|
|
|
|
|
if (t.id === parentId)
|
|
|
|
|
const buildTagByCategory = (post: Post): TagByCategory => {
|
|
|
|
|
const tagsTmp = { } as TagByCategory
|
|
|
|
|
|
|
|
|
|
for (const tag of post.tags)
|
|
|
|
|
{
|
|
|
|
|
const children = (t.children ?? []).filter (c => c.id !== childId)
|
|
|
|
|
return { ...t, children }
|
|
|
|
|
if (!(tag.category in tagsTmp))
|
|
|
|
|
tagsTmp[tag.category] = []
|
|
|
|
|
|
|
|
|
|
tagsTmp[tag.category].push (tag)
|
|
|
|
|
}
|
|
|
|
|
return t.children ? { ...t, children: detachEdge (t.children, parentId, childId) } : t
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 insertRootAt = (
|
|
|
|
|
roots: Tag[],
|
|
|
|
|
index: number,
|
|
|
|
|
tag: Tag,
|
|
|
|
|
): Tag[] => {
|
|
|
|
|
const without = roots.filter (t => t.id !== tag.id)
|
|
|
|
|
const next = without.slice ()
|
|
|
|
|
next.splice (Math.min (Math.max (index, 0), next.length), 0, tag)
|
|
|
|
|
return next
|
|
|
|
|
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') ?? '' } })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -220,14 +151,32 @@ type Props = { post: Post | null }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export default (({ post }: Props) => {
|
|
|
|
|
const [dragging, setDragging] = useState (false)
|
|
|
|
|
const [saving, setSaving] = useState (false)
|
|
|
|
|
const [tags, setTags] = useState ({ } as TagByCategory)
|
|
|
|
|
|
|
|
|
|
const suppressClickRef = useRef (false)
|
|
|
|
|
|
|
|
|
|
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) => {
|
|
|
|
|
if (saving)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
const activeKind = e.active.data.current?.kind
|
|
|
|
|
if (activeKind !== 'tag')
|
|
|
|
|
return
|
|
|
|
@@ -240,15 +189,27 @@ export default (({ post }: Props) => {
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
const parent = findTag (tags, parentId)
|
|
|
|
|
|
|
|
|
|
if (!(child)
|
|
|
|
|
|| !(parent)
|
|
|
|
|
|| isDescendant (child, parentId))
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if (fromParentId != null)
|
|
|
|
|
{
|
|
|
|
|
await axios.delete (
|
|
|
|
@@ -260,68 +221,54 @@ export default (({ post }: Props) => {
|
|
|
|
|
`${ API_BASE_URL }/tags/${ parentId }/children/${ childId }`,
|
|
|
|
|
{ },
|
|
|
|
|
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
toast ({ description: '管理者権限が必要です.' })
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTags (prev => {
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
await reloadTags ()
|
|
|
|
|
toast ({
|
|
|
|
|
title: '上位タグ対応追加',
|
|
|
|
|
description: `《${ child?.name }》を《${ parent?.name }》の子タグに設定しました.` })
|
|
|
|
|
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'slot':
|
|
|
|
|
{
|
|
|
|
|
const cat: Category | undefined = e.over?.data.current?.cat
|
|
|
|
|
|
|
|
|
|
if (fromParentId == null
|
|
|
|
|
|| !(cat)
|
|
|
|
|
|| cat !== findTag (tags, childId)?.category)
|
|
|
|
|
if (!(cat) || !(child))
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
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') ?? '' } })
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
toast ({ description: '管理者権限が必要です.' })
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const fromParent = fromParentId == null ? null : findTag (tags, fromParentId)
|
|
|
|
|
|
|
|
|
|
setTags (prev => {
|
|
|
|
|
const child = findTag (prev, childId)
|
|
|
|
|
if (!(child))
|
|
|
|
|
return prev
|
|
|
|
|
|
|
|
|
|
const next: TagByCategory = { ...prev }
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
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> = {
|
|
|
|
|
deerjikist: 'ニジラー',
|
|
|
|
@@ -357,6 +304,7 @@ export default (({ post }: Props) => {
|
|
|
|
|
<DndContext
|
|
|
|
|
sensors={sensors}
|
|
|
|
|
onDragStart={() => {
|
|
|
|
|
setDragging (true)
|
|
|
|
|
suppressClickRef.current = true
|
|
|
|
|
document.body.style.userSelect = 'none'
|
|
|
|
|
getSelection?.()?.removeAllRanges?.()
|
|
|
|
@@ -367,23 +315,24 @@ export default (({ post }: Props) => {
|
|
|
|
|
}, { 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>
|
|
|
|
|
{CATEGORIES.map ((cat: Category) => cat in tags && (
|
|
|
|
|
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
|
|
|
|
|
<motion.div layout className="my-3" key={cat}>
|
|
|
|
|
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
|
|
|
|
|
|
|
|
|
|
<motion.ul layout>
|
|
|
|
|
<AnimatePresence initial={false}>
|
|
|
|
|
{tags[cat].map (tag => (
|
|
|
|
|
<>
|
|
|
|
|
{renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)}
|
|
|
|
|
</>))}
|
|
|
|
|
{(tags[cat] ?? []).flatMap (tag => (
|
|
|
|
|
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))}
|
|
|
|
|
<DropSlot cat={cat}/>
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
</motion.ul>
|
|
|
|
|