|
|
|
@@ -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 |
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
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') ?? '' } }) |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@@ -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,86 +189,84 @@ export default (({ post }: Props) => { |
|
|
|
if (childId == null || !(overKind)) |
|
|
|
return |
|
|
|
|
|
|
|
switch (overKind) |
|
|
|
{ |
|
|
|
case 'tag': |
|
|
|
const parentId: number | undefined = e.over?.data.current?.tagId |
|
|
|
if (parentId == null || childId === parentId) |
|
|
|
return |
|
|
|
|
|
|
|
try |
|
|
|
{ |
|
|
|
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') ?? '' } }) |
|
|
|
} |
|
|
|
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) |
|
|
|
}) |
|
|
|
|
|
|
|
break |
|
|
|
|
|
|
|
case 'slot': |
|
|
|
const cat: Category | undefined = e.over?.data.current?.cat |
|
|
|
const child = findTag (tags, childId) |
|
|
|
|
|
|
|
if (fromParentId == null |
|
|
|
|| !(cat) |
|
|
|
|| cat !== findTag (tags, childId)?.category) |
|
|
|
return |
|
|
|
try |
|
|
|
{ |
|
|
|
setSaving (true) |
|
|
|
|
|
|
|
try |
|
|
|
{ |
|
|
|
await axios.delete ( |
|
|
|
`${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`, |
|
|
|
{ headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) |
|
|
|
} |
|
|
|
catch |
|
|
|
switch (overKind) |
|
|
|
{ |
|
|
|
toast ({ description: '管理者権限が必要です.' }) |
|
|
|
|
|
|
|
return |
|
|
|
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 |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
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 |
|
|
|
}) |
|
|
|
|
|
|
|
break |
|
|
|
} |
|
|
|
catch |
|
|
|
{ |
|
|
|
toast ({ title: '上位タグ対応失敗', description: '独裁者である必要があります.' }) |
|
|
|
} |
|
|
|
finally |
|
|
|
{ |
|
|
|
setSaving (false) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@@ -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> |
|
|
|
|