【再掲】feat: D & D による上位タグ設定(#184) (#211)
#184 修正 Merge branch 'main' into feature/184 Merge branch 'main' into feature/184 Merge branch 'main' into feature/184 Merge branch 'main' into feature/184 Merge remote-tracking branch 'origin/main' into feature/184 Merge branch 'main' into feature/184 Merge branch 'main' into feature/184 Merge branch 'main' into feature/184 Merge branch 'main' into feature/184 Merge branch 'main' into feature/184 Merge branch 'main' into feature/184 Merge remote-tracking branch 'origin/main' into feature/184 Merge branch 'main' into feature/184 Merge remote-tracking branch 'origin/main' into feature/184 Merge remote-tracking branch 'origin/main' into feature/184 Merge branch 'main' into feature/184 Merge branch 'main' into feature/184 #184 #184 #184 #184 #184 #184 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: #211
This commit was merged in pull request #211.
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
class TagChildrenController < ApplicationController
|
||||||
|
def create
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
return head :forbidden unless current_user.admin?
|
||||||
|
|
||||||
|
parent_id = params[:parent_id]
|
||||||
|
child_id = params[:child_id]
|
||||||
|
return head :bad_request if parent_id.blank? || child_id.blank?
|
||||||
|
|
||||||
|
Tag.find(parent_id).children << Tag.find(child_id) rescue nil
|
||||||
|
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
return head :forbidden unless current_user.admin?
|
||||||
|
|
||||||
|
parent_id = params[:parent_id]
|
||||||
|
child_id = params[:child_id]
|
||||||
|
return head :bad_request if parent_id.blank? || child_id.blank?
|
||||||
|
|
||||||
|
Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil
|
||||||
|
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -44,4 +44,18 @@ class TagsController < ApplicationController
|
|||||||
head :not_found
|
head :not_found
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
resources :nico_tags, path: 'tags/nico', only: [:index, :update]
|
resources :nico_tags, path: 'tags/nico', only: [:index, :update]
|
||||||
|
|
||||||
resources :tags, only: [:index, :show] do
|
scope 'tags/:parent_id/children', controller: :tag_children do
|
||||||
|
post ':child_id', action: :create
|
||||||
|
delete ':child_id', action: :destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
resources :tags, only: [:index, :show, :update] do
|
||||||
collection do
|
collection do
|
||||||
get :autocomplete
|
get :autocomplete
|
||||||
get 'name/:name', action: :show_by_name
|
get 'name/:name', action: :show_by_name
|
||||||
|
|||||||
Generated
+56
@@ -9,6 +9,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
@@ -371,6 +374,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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/modifiers": {
|
||||||
|
"version": "9.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.4",
|
"version": "0.25.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
visibility: dragging ? 'hidden' : 'visible' }
|
||||||
|
|
||||||
|
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', over && 'ring-2 ring-offset-2')}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}>
|
||||||
|
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||||
|
</div>)
|
||||||
|
}) satisfies FC<Props>
|
||||||
@@ -1,15 +1,29 @@
|
|||||||
|
import { DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors } from '@dnd-kit/core'
|
||||||
|
import { restrictToWindowEdges } from '@dnd-kit/modifiers'
|
||||||
|
import axios from 'axios'
|
||||||
|
import toCamel from 'camelcase-keys'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
import TagSearch from '@/components/TagSearch'
|
import TagSearch from '@/components/TagSearch'
|
||||||
import SectionTitle from '@/components/common/SectionTitle'
|
import SectionTitle from '@/components/common/SectionTitle'
|
||||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||||
|
import { toast } from '@/components/ui/use-toast'
|
||||||
|
import { API_BASE_URL } from '@/config'
|
||||||
import { CATEGORIES } from '@/consts'
|
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'
|
import type { Category, Post, Tag } from '@/types'
|
||||||
|
|
||||||
@@ -17,9 +31,11 @@ type TagByCategory = { [key in Category]: Tag[] }
|
|||||||
|
|
||||||
|
|
||||||
const renderTagTree = (
|
const renderTagTree = (
|
||||||
tag: Tag,
|
tag: Tag,
|
||||||
nestLevel: number,
|
nestLevel: number,
|
||||||
path: string,
|
path: string,
|
||||||
|
suppressClickRef: MutableRefObject<boolean>,
|
||||||
|
parentTagId?: number,
|
||||||
): ReactNode[] => {
|
): ReactNode[] => {
|
||||||
const key = `${ path }-${ tag.id }`
|
const key = `${ path }-${ tag.id }`
|
||||||
|
|
||||||
@@ -29,14 +45,109 @@ const renderTagTree = (
|
|||||||
layout
|
layout
|
||||||
transition={{ duration: .2, ease: 'easeOut' }}
|
transition={{ duration: .2, ease: 'easeOut' }}
|
||||||
className="mb-1">
|
className="mb-1">
|
||||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
<DraggableDroppableTagRow
|
||||||
|
tag={tag}
|
||||||
|
nestLevel={nestLevel}
|
||||||
|
pathKey={key}
|
||||||
|
parentTagId={parentTagId}
|
||||||
|
suppressClickRef={suppressClickRef}/>
|
||||||
</motion.li>)
|
</motion.li>)
|
||||||
|
|
||||||
return [self,
|
return [
|
||||||
...((tag.children
|
self,
|
||||||
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
...((tag.children
|
||||||
.flatMap (child => renderTagTree (child, nestLevel + 1, key)))
|
?.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>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -44,8 +155,126 @@ type Props = { post: Post | null }
|
|||||||
|
|
||||||
|
|
||||||
export default (({ post }: Props) => {
|
export default (({ post }: Props) => {
|
||||||
|
const [activeTagId, setActiveTagId] = useState<number | null> (null)
|
||||||
|
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 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> = {
|
const categoryNames: Record<Category, string> = {
|
||||||
deerjikist: 'ニジラー',
|
deerjikist: 'ニジラー',
|
||||||
meme: '原作・ネタ元・ミーム等',
|
meme: '原作・ネタ元・ミーム等',
|
||||||
@@ -77,63 +306,103 @@ export default (({ post }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<SidebarComponent>
|
<SidebarComponent>
|
||||||
<TagSearch/>
|
<TagSearch/>
|
||||||
<motion.div key={post?.id ?? 0} layout>
|
<DndContext
|
||||||
{CATEGORIES.map ((cat: Category) => cat in tags && (
|
sensors={sensors}
|
||||||
<motion.div layout className="my-3" key={cat}>
|
onDragStart={e => {
|
||||||
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
|
if (e.active.data.current?.kind === 'tag')
|
||||||
|
setActiveTagId (e.active.data.current?.tagId ?? null)
|
||||||
|
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={() => {
|
||||||
|
setActiveTagId (null)
|
||||||
|
setDragging (false)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
suppressClickRef.current = false
|
||||||
|
}}
|
||||||
|
onDragEnd={async e => {
|
||||||
|
setActiveTagId (null)
|
||||||
|
setDragging (false)
|
||||||
|
await onDragEnd (e)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}}
|
||||||
|
modifiers={[restrictToWindowEdges]}>
|
||||||
|
<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>
|
<motion.ul layout>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
|
{(tags[cat] ?? []).flatMap (tag => (
|
||||||
</AnimatePresence>
|
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))}
|
||||||
</motion.ul>
|
<DropSlot cat={cat}/>
|
||||||
</motion.div>))}
|
</AnimatePresence>
|
||||||
{post && (
|
</motion.ul>
|
||||||
<div>
|
</motion.div>))}
|
||||||
<SectionTitle>情報</SectionTitle>
|
{post && (
|
||||||
<ul>
|
<div>
|
||||||
<li>Id.: {post.id}</li>
|
<SectionTitle>情報</SectionTitle>
|
||||||
{/* TODO: uploadedUser の取得を対応したらコメント外す */}
|
<ul>
|
||||||
{/*
|
<li>Id.: {post.id}</li>
|
||||||
<li>
|
{/* TODO: uploadedUser の取得を対応したらコメント外す */}
|
||||||
<>耕作者: </>
|
{/*
|
||||||
{post.uploadedUser
|
<li>
|
||||||
? (
|
<>耕作者: </>
|
||||||
<Link to={`/users/${ post.uploadedUser.id }`}>
|
{post.uploadedUser
|
||||||
{post.uploadedUser.name || '名もなきニジラー'}
|
? (
|
||||||
</Link>)
|
<Link to={`/users/${ post.uploadedUser.id }`}>
|
||||||
: 'bot操作'}
|
{post.uploadedUser.name || '名もなきニジラー'}
|
||||||
</li>
|
</Link>)
|
||||||
*/}
|
: 'bot操作'}
|
||||||
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li>
|
</li>
|
||||||
<li>
|
*/}
|
||||||
<>リンク: </>
|
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li>
|
||||||
<a
|
<li>
|
||||||
className="break-all"
|
<>リンク: </>
|
||||||
href={post.url}
|
<a
|
||||||
target="_blank"
|
className="break-all"
|
||||||
rel="noopener noreferrer nofollow">
|
href={post.url}
|
||||||
{post.url}
|
target="_blank"
|
||||||
</a>
|
rel="noopener noreferrer nofollow">
|
||||||
</li>
|
{post.url}
|
||||||
<li>
|
</a>
|
||||||
{/* TODO: 表示形式きしょすぎるので何とかする */}
|
</li>
|
||||||
<>オリジナルの投稿日時: </>
|
<li>
|
||||||
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
|
{/* TODO: 表示形式きしょすぎるので何とかする */}
|
||||||
? '不明'
|
<>オリジナルの投稿日時: </>
|
||||||
: (
|
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
|
||||||
<>
|
? '不明'
|
||||||
{post.originalCreatedFrom
|
: (
|
||||||
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
|
<>
|
||||||
{post.originalCreatedBefore
|
{post.originalCreatedFrom
|
||||||
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
|
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
|
||||||
</>)}
|
{post.originalCreatedBefore
|
||||||
</li>
|
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
|
||||||
<li>
|
</>)}
|
||||||
<Link to={`/posts/changes?id=${ post.id }`}>履歴</Link>
|
</li>
|
||||||
</li>
|
<li>
|
||||||
</ul>
|
<Link to={`/posts/changes?id=${ post.id }`}>履歴</Link>
|
||||||
</div>)}
|
</li>
|
||||||
</motion.div>
|
</ul>
|
||||||
|
</div>)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<DragOverlay adjustScale={false}>
|
||||||
|
<div className="pointer-events-none">
|
||||||
|
{activeTagId != null && (() => {
|
||||||
|
const tag = findTag (tags, activeTagId)
|
||||||
|
return tag && <TagLink tag={tag}/>
|
||||||
|
}) ()}
|
||||||
|
</div>
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
</SidebarComponent>)
|
</SidebarComponent>)
|
||||||
}) satisfies FC<Props>
|
}) satisfies FC<Props>
|
||||||
|
|||||||
Reference in New Issue
Block a user