|
|
|
@@ -1,26 +1,15 @@ |
|
|
|
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 { useEffect, useRef, useState } from 'react' |
|
|
|
import { useEffect, useState } from 'react' |
|
|
|
import { Link } from 'react-router-dom' |
|
|
|
|
|
|
|
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow' |
|
|
|
import TagLink from '@/components/TagLink' |
|
|
|
import TagSearch from '@/components/TagSearch' |
|
|
|
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 { API_BASE_URL } from '@/config' |
|
|
|
import { CATEGORIES } from '@/consts' |
|
|
|
|
|
|
|
import type { DragEndEvent } from '@dnd-kit/core' |
|
|
|
import type { FC, MutableRefObject, ReactNode } from 'react' |
|
|
|
import type { FC, ReactNode } from 'react' |
|
|
|
|
|
|
|
import type { Category, Post, Tag } from '@/types' |
|
|
|
|
|
|
|
@@ -28,11 +17,9 @@ type TagByCategory = { [key in Category]: Tag[] } |
|
|
|
|
|
|
|
|
|
|
|
const renderTagTree = ( |
|
|
|
tag: Tag, |
|
|
|
nestLevel: number, |
|
|
|
path: string, |
|
|
|
suppressClickRef: MutableRefObject<boolean>, |
|
|
|
parentTagId?: number, |
|
|
|
tag: Tag, |
|
|
|
nestLevel: number, |
|
|
|
path: string, |
|
|
|
): ReactNode[] => { |
|
|
|
const key = `${ path }-${ tag.id }` |
|
|
|
|
|
|
|
@@ -42,109 +29,14 @@ const renderTagTree = ( |
|
|
|
layout |
|
|
|
transition={{ duration: .2, ease: 'easeOut' }} |
|
|
|
className="mb-1"> |
|
|
|
<DraggableDroppableTagRow |
|
|
|
tag={tag} |
|
|
|
nestLevel={nestLevel} |
|
|
|
pathKey={key} |
|
|
|
parentTagId={parentTagId} |
|
|
|
suppressClickRef={suppressClickRef}/> |
|
|
|
<TagLink tag={tag} nestLevel={nestLevel}/> |
|
|
|
</motion.li>) |
|
|
|
|
|
|
|
return [ |
|
|
|
self, |
|
|
|
...((tag.children |
|
|
|
?.sort ((a, b) => a.name < b.name ? -1 : 1) |
|
|
|
.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>) |
|
|
|
return [self, |
|
|
|
...((tag.children |
|
|
|
?.sort ((a, b) => a.name < b.name ? -1 : 1) |
|
|
|
.flatMap (child => renderTagTree (child, nestLevel + 1, key))) |
|
|
|
?? [])] |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@@ -152,125 +44,8 @@ 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 (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> = { |
|
|
|
deerjikist: 'ニジラー', |
|
|
|
meme: '原作・ネタ元・ミーム等', |
|
|
|
@@ -302,89 +77,63 @@ export default (({ post }: Props) => { |
|
|
|
return ( |
|
|
|
<SidebarComponent> |
|
|
|
<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> |
|
|
|
{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] ?? []).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 |
|
|
|
? ( |
|
|
|
<Link to={`/users/${ post.uploadedUser.id }`}> |
|
|
|
{post.uploadedUser.name || '名もなきニジラー'} |
|
|
|
</Link>) |
|
|
|
: 'bot操作'} |
|
|
|
</li> |
|
|
|
*/} |
|
|
|
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li> |
|
|
|
<li> |
|
|
|
<>リンク: </> |
|
|
|
<a |
|
|
|
className="break-all" |
|
|
|
href={post.url} |
|
|
|
target="_blank" |
|
|
|
rel="noopener noreferrer nofollow"> |
|
|
|
{post.url} |
|
|
|
</a> |
|
|
|
</li> |
|
|
|
<li> |
|
|
|
{/* TODO: 表示形式きしょすぎるので何とかする */} |
|
|
|
<>オリジナルの投稿日時: </> |
|
|
|
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore) |
|
|
|
? '不明' |
|
|
|
: ( |
|
|
|
<> |
|
|
|
{post.originalCreatedFrom |
|
|
|
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} |
|
|
|
{post.originalCreatedBefore |
|
|
|
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} |
|
|
|
</>)} |
|
|
|
</li> |
|
|
|
<li> |
|
|
|
<Link to={`/posts/changes?id=${ post.id }`}>履歴</Link> |
|
|
|
</li> |
|
|
|
</ul> |
|
|
|
</div>)} |
|
|
|
</motion.div> |
|
|
|
</DndContext> |
|
|
|
<motion.div key={post?.id ?? 0} layout> |
|
|
|
{CATEGORIES.map ((cat: Category) => cat in tags && ( |
|
|
|
<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 }`))} |
|
|
|
</AnimatePresence> |
|
|
|
</motion.ul> |
|
|
|
</motion.div>))} |
|
|
|
{post && ( |
|
|
|
<div> |
|
|
|
<SectionTitle>情報</SectionTitle> |
|
|
|
<ul> |
|
|
|
<li>Id.: {post.id}</li> |
|
|
|
{/* TODO: uploadedUser の取得を対応したらコメント外す */} |
|
|
|
{/* |
|
|
|
<li> |
|
|
|
<>耕作者: </> |
|
|
|
{post.uploadedUser |
|
|
|
? ( |
|
|
|
<Link to={`/users/${ post.uploadedUser.id }`}> |
|
|
|
{post.uploadedUser.name || '名もなきニジラー'} |
|
|
|
</Link>) |
|
|
|
: 'bot操作'} |
|
|
|
</li> |
|
|
|
*/} |
|
|
|
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li> |
|
|
|
<li> |
|
|
|
<>リンク: </> |
|
|
|
<a |
|
|
|
className="break-all" |
|
|
|
href={post.url} |
|
|
|
target="_blank" |
|
|
|
rel="noopener noreferrer nofollow"> |
|
|
|
{post.url} |
|
|
|
</a> |
|
|
|
</li> |
|
|
|
<li> |
|
|
|
{/* TODO: 表示形式きしょすぎるので何とかする */} |
|
|
|
<>オリジナルの投稿日時: </> |
|
|
|
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore) |
|
|
|
? '不明' |
|
|
|
: ( |
|
|
|
<> |
|
|
|
{post.originalCreatedFrom |
|
|
|
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} |
|
|
|
{post.originalCreatedBefore |
|
|
|
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} |
|
|
|
</>)} |
|
|
|
</li> |
|
|
|
<li> |
|
|
|
<Link to={`/posts/changes?id=${ post.id }`}>履歴</Link> |
|
|
|
</li> |
|
|
|
</ul> |
|
|
|
</div>)} |
|
|
|
</motion.div> |
|
|
|
</SidebarComponent>) |
|
|
|
}) satisfies FC<Props> |