Browse Source

#184

pull/186/head
みてるぞ 3 weeks ago
parent
commit
c117c6d680
4 changed files with 377 additions and 63 deletions
  1. +41
    -0
      frontend/package-lock.json
  2. +2
    -0
      frontend/package.json
  3. +93
    -0
      frontend/src/components/DraggableDroppableTagRow.tsx
  4. +241
    -63
      frontend/src/components/TagDetailSidebar.tsx

+ 41
- 0
frontend/package-lock.json View File

@@ -9,6 +9,8 @@
"version": "0.0.0",
"license": "ISC",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-switch": "^1.2.5",
@@ -370,6 +372,45 @@
"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": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",


+ 2
- 0
frontend/package.json View File

@@ -11,6 +11,8 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-switch": "^1.2.5",


+ 93
- 0
frontend/src/components/DraggableDroppableTagRow.tsx View File

@@ -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 touch-none', over && 'ring-2 ring-offset-2')}
{...attributes}
{...listeners}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</div>)
}) satisfies FC<Props>

+ 241
- 63
frontend/src/components/TagDetailSidebar.tsx View File

@@ -1,14 +1,16 @@
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'

import TagLink from '@/components/TagLink'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import TagSearch from '@/components/TagSearch'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent'
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'

@@ -16,9 +18,11 @@ type TagByCategory = { [key in Category]: Tag[] }


const renderTagTree = (
tag: Tag,
nestLevel: number,
path: string,
tag: Tag,
nestLevel: number,
path: string,
suppressClickRef: MutableRefObject<boolean>,
parentTagId?: number,
): ReactNode[] => {
const key = `${ path }-${ tag.id }`

@@ -28,23 +32,176 @@ const renderTagTree = (
layout
transition={{ duration: .2, ease: 'easeOut' }}
className="mb-1">
<TagLink tag={tag} nestLevel={nestLevel}/>
<DraggableDroppableTagRow
tag={tag}
nestLevel={nestLevel}
pathKey={key}
parentTagId={parentTagId}
suppressClickRef={suppressClickRef}/>
</motion.li>)

return [self,
...((tag.children
?.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 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)

return next
}


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
}


type Props = { post: Post | null }


export default (({ post }: Props) => {
const [tags, setTags] = useState ({ } as TagByCategory)

const suppressClickRef = useRef (false)

const sensors = useSensors (
useSensor (PointerSensor, { activationConstraint: { distance: 6 } }))

const onDragEnd = async (e: DragEndEvent) => {
const childId: number | undefined = e.active.data.current?.tagId
const parentId: number | undefined = e.over?.data.current?.tagId

if (!(childId) || !(parentId))
return

if (childId === parentId)
return

setTags (prev => {
const child = findTag (prev, childId)
const parent = findTag (prev, parentId)
if (!(child) || !(parent))
return prev

if (isDescendant (child, parentId))
return prev

return attachChildOptimistic (prev, parentId, childId)
})
}

const categoryNames: Record<Category, string> = {
deerjikist: 'ニジラー',
meme: '原作・ネタ元・ミーム等',
@@ -76,60 +233,81 @@ export default (({ post }: Props) => {
return (
<SidebarComponent>
<TagSearch/>
<motion.div 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>
</ul>
</div>)}
</motion.div>
<DndContext
sensors={sensors}
onDragStart={() => {
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={() => {
document.body.style.userSelect = ''
}}
onDragEnd={e => {
onDragEnd (e)
document.body.style.userSelect = ''
}}>
<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 }`, suppressClickRef, undefined)))}
</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>
</ul>
</div>)}
</motion.div>
</DndContext>
</SidebarComponent>)
}) satisfies FC<Props>

Loading…
Cancel
Save