From c117c6d680053710c3471ded7bfb6c84c5f058df Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 18 Dec 2025 01:46:31 +0900 Subject: [PATCH 1/5] #184 --- frontend/package-lock.json | 41 +++ frontend/package.json | 2 + .../components/DraggableDroppableTagRow.tsx | 93 ++++++ frontend/src/components/TagDetailSidebar.tsx | 304 ++++++++++++++---- 4 files changed, 377 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/DraggableDroppableTagRow.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43fbc44..46c04cd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index cbe44ff..8de6bae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx new file mode 100644 index 0000000..9b20183 --- /dev/null +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -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 } + + +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 ( +
{ + 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}> + +
) +}) satisfies FC diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 0006738..57ef996 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -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, + parentTagId?: number, ): ReactNode[] => { const key = `${ path }-${ tag.id }` @@ -28,23 +32,176 @@ const renderTagTree = ( layout transition={{ duration: .2, ease: 'easeOut' }} className="mb-1"> - + ) 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 = { deerjikist: 'ニジラー', meme: '原作・ネタ元・ミーム等', @@ -76,60 +233,81 @@ export default (({ post }: Props) => { return ( - - {CATEGORIES.map ((cat: Category) => cat in tags && ( - - {categoryNames[cat]} - - - - {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))} - - - ))} - {post && ( -
- 情報 -
    -
  • Id.: {post.id}
  • - {/* TODO: uploadedUser の取得を対応したらコメント外す */} - {/* -
  • - <>耕作者: - {post.uploadedUser - ? ( - - {post.uploadedUser.name || '名もなきニジラー'} - ) - : 'bot操作'} -
  • - */} -
  • 耕作日時: {(new Date (post.createdAt)).toLocaleString ()}
  • -
  • - <>リンク: - - {post.url} - -
  • -
  • - {/* TODO: 表示形式きしょすぎるので何とかする */} - <>オリジナルの投稿日時: - {!(post.originalCreatedFrom) && !(post.originalCreatedBefore) - ? '不明' - : ( - <> - {post.originalCreatedFrom - && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} - {post.originalCreatedBefore - && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} - )} -
  • -
-
)} -
+ { + 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 = '' + }}> + + {CATEGORIES.map ((cat: Category) => cat in tags && ( + + {categoryNames[cat]} + + + + {tags[cat].map (tag => ( + renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))} + + + ))} + {post && ( +
+ 情報 +
    +
  • Id.: {post.id}
  • + {/* TODO: uploadedUser の取得を対応したらコメント外す */} + {/* +
  • + <>耕作者: + {post.uploadedUser + ? ( + + {post.uploadedUser.name || '名もなきニジラー'} + ) + : 'bot操作'} +
  • + */} +
  • 耕作日時: {(new Date (post.createdAt)).toLocaleString ()}
  • +
  • + <>リンク: + + {post.url} + +
  • +
  • + {/* TODO: 表示形式きしょすぎるので何とかする */} + <>オリジナルの投稿日時: + {!(post.originalCreatedFrom) && !(post.originalCreatedBefore) + ? '不明' + : ( + <> + {post.originalCreatedFrom + && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} + {post.originalCreatedBefore + && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} + )} +
  • +
+
)} +
+
) }) satisfies FC -- 2.34.1 From 7ba814b5f9c1724d7427f01bf4a34080ad49c922 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 18 Dec 2025 02:27:33 +0900 Subject: [PATCH 2/5] #184 --- frontend/src/components/TagDetailSidebar.tsx | 107 ++++++++++++++++--- 1 file changed, 92 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 57ef996..18256c7 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -1,4 +1,8 @@ -import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' +import { DndContext, + PointerSensor, + useDroppable, + useSensor, + useSensors } from '@dnd-kit/core' import { AnimatePresence, motion } from 'framer-motion' import { useEffect, useRef, useState } from 'react' @@ -168,6 +172,44 @@ const findTag = ( } +const detachEdge = ( + nodes: Tag[], + parentId: number, + childId: number, +): Tag[] => nodes.map (t => { + if (t.id === parentId) + { + const children = (t.children ?? []).filter (c => c.id !== childId) + return { ...t, children } + } + 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 +} + + +const DropSlot = ({ cat, index }: { cat: Category, index: number }) => { + const { setNodeRef, isOver: over } = useDroppable ({ + id: `slot:${ cat }:${ index }`, + data: { kind: 'slot', cat, index } }) + + return ( +
  • + {over &&
    } +
  • ) +} + + type Props = { post: Post | null } @@ -180,26 +222,58 @@ export default (({ post }: Props) => { useSensor (PointerSensor, { activationConstraint: { distance: 6 } })) const onDragEnd = async (e: DragEndEvent) => { + const activeKind = e.active.data.current?.kind + if (activeKind !== 'tag') + return + const childId: number | undefined = e.active.data.current?.tagId - const parentId: number | undefined = e.over?.data.current?.tagId + const fromParentId: number | undefined = e.active.data.current?.parentTagId - if (!(childId) || !(parentId)) - return + const overKind = e.over?.data.current?.kind - if (childId === parentId) + if (!(childId) || !(overKind)) return - setTags (prev => { - const child = findTag (prev, childId) - const parent = findTag (prev, parentId) - if (!(child) || !(parent)) - return prev + if (overKind === 'tag') + { + const parentId: number | undefined = e.over?.data.current?.tagId + if (!(parentId) || childId === parentId) + return + + setTags (prev => { + const child = findTag (prev, childId) + const parent = findTag (prev, parentId) + if (!(child) || !(parent) || isDescendant (child, parentId)) + return prev + + return attachChildOptimistic (prev, parentId, childId) + }) - if (isDescendant (child, parentId)) - return prev + return + } - return attachChildOptimistic (prev, parentId, childId) - }) + if (overKind === 'slot') + { + const cat: Category | undefined = e.over?.data.current?.cat + const index: number | undefined = e.over?.data.current?.index + if (!(cat) || index == null) + return + + setTags (prev => { + const child = findTag (prev, childId) + if (!(child)) + return prev + + const next: TagByCategory = { ...prev } + + if (fromParentId) + next[cat] = detachEdge (next[cat], fromParentId, childId) as any + + next[cat] = insertRootAt (next[cat], index, child) + + return next + }) + } } const categoryNames: Record = { @@ -260,7 +334,10 @@ export default (({ post }: Props) => { {tags[cat].map (tag => ( - renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))} + <> + {renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)} + ))} + ))} -- 2.34.1 From fb9e708261faa30a0e9e2326b7afd82d79154f91 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 19 Dec 2025 01:37:08 +0900 Subject: [PATCH 3/5] #184 --- .../controllers/tag_children_controller.rb | 27 +++ backend/config/routes.rb | 2 + frontend/src/components/TagDetailSidebar.tsx | 157 ++++++++---------- 3 files changed, 100 insertions(+), 86 deletions(-) create mode 100644 backend/app/controllers/tag_children_controller.rb diff --git a/backend/app/controllers/tag_children_controller.rb b/backend/app/controllers/tag_children_controller.rb new file mode 100644 index 0000000..4b352b4 --- /dev/null +++ b/backend/app/controllers/tag_children_controller.rb @@ -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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 0733991..db8d721 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -3,6 +3,8 @@ Rails.application.routes.draw do put 'tags/nico/:id', to: 'nico_tags#update' get 'tags/autocomplete', to: 'tags#autocomplete' get 'tags/name/:name', to: 'tags#show_by_name' + post 'tags/:parent_id/children/:child_id', to: 'tag_children#create' + delete 'tags/:parent_id/children/:child_id', to: 'tag_children#destroy' get 'posts/random', to: 'posts#random' get 'posts/changes', to: 'posts#changes' post 'posts/:id/viewed', to: 'posts#viewed' diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 18256c7..7a54a85 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -3,6 +3,7 @@ import { DndContext, useDroppable, useSensor, useSensors } from '@dnd-kit/core' +import axios from 'axios' import { AnimatePresence, motion } from 'framer-motion' import { useEffect, useRef, useState } from 'react' @@ -11,6 +12,7 @@ import TagSearch from '@/components/TagSearch' import SectionTitle from '@/components/common/SectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle' import SidebarComponent from '@/components/layout/SidebarComponent' +import { API_BASE_URL } from '@/config' import { CATEGORIES } from '@/consts' import type { DragEndEvent } from '@dnd-kit/core' @@ -44,38 +46,12 @@ const renderTagTree = ( suppressClickRef={suppressClickRef}/> ) - return [self, - ...((tag.children - ?.sort ((a, b) => a.name < b.name ? -1 : 1) - .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 } + return [ + self, + ...((tag.children + ?.sort ((a, b) => a.name < b.name ? -1 : 1) + .flatMap (child => renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id))) + ?? [])] } @@ -100,30 +76,6 @@ const addAsChild = ( } -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, @@ -210,6 +162,12 @@ const DropSlot = ({ cat, index }: { cat: Category, index: number }) => { } +const removeFromRoot = ( + roots: Tag[], + childId: number, +): Tag[] => roots.filter (t => t.id !== childId) + + type Props = { post: Post | null } @@ -234,46 +192,73 @@ export default (({ post }: Props) => { if (!(childId) || !(overKind)) return - if (overKind === 'tag') - { - const parentId: number | undefined = e.over?.data.current?.tagId - if (!(parentId) || childId === parentId) - return + switch (overKind) + { + case 'tag': + const parentId: number | undefined = e.over?.data.current?.tagId + if (!(parentId) || childId === parentId) + return + + setTags (prev => { + const child = findTag (prev, childId) + const parent = findTag (prev, parentId) + if (!(child) || !(parent) || isDescendant (child, parentId)) + return prev + + const cat = child.category + const next: TagByCategory = { ...prev } - setTags (prev => { - const child = findTag (prev, childId) - const parent = findTag (prev, parentId) - if (!(child) || !(parent) || isDescendant (child, parentId)) - return prev + if (fromParentId) + next[cat] = detachEdge (next[cat], fromParentId, childId) + else + next[cat] = removeFromRoot (next[cat], childId) - return attachChildOptimistic (prev, parentId, childId) - }) + next[cat] = addAsChild (next[cat], parentId, child) + + return next + }) + if (fromParentId) + { + 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') ?? '' } }) + + break + + case 'slot': + const cat: Category | undefined = e.over?.data.current?.cat + const index: number | undefined = e.over?.data.current?.index + if (!(cat) || index == null) return - } - if (overKind === 'slot') - { - const cat: Category | undefined = e.over?.data.current?.cat - const index: number | undefined = e.over?.data.current?.index - if (!(cat) || index == null) - return + setTags (prev => { + const child = findTag (prev, childId) + if (!(child)) + return prev - setTags (prev => { - const child = findTag (prev, childId) - if (!(child)) - return prev + const next: TagByCategory = { ...prev } - const next: TagByCategory = { ...prev } + if (fromParentId) + next[cat] = detachEdge (next[cat], fromParentId, childId) as any - if (fromParentId) - next[cat] = detachEdge (next[cat], fromParentId, childId) as any + next[cat] = insertRootAt (next[cat], index, child) - next[cat] = insertRootAt (next[cat], index, child) + return next + }) - return next - }) - } + await axios.delete ( + `${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`, + { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) + + break + } } const categoryNames: Record = { -- 2.34.1 From 32e1054a8f142c9e780fbbbc20d81a2640e37b59 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 20 Dec 2025 02:38:13 +0900 Subject: [PATCH 4/5] #184 --- frontend/src/components/TagDetailSidebar.tsx | 146 +++++++++++++------ 1 file changed, 104 insertions(+), 42 deletions(-) diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 7a54a85..ca4e23c 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -12,6 +12,7 @@ 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' @@ -55,6 +56,31 @@ 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, @@ -76,6 +102,34 @@ const addAsChild = ( } +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, @@ -150,10 +204,10 @@ const insertRootAt = ( } -const DropSlot = ({ cat, index }: { cat: Category, index: number }) => { +const DropSlot = ({ cat }: { cat: Category }) => { const { setNodeRef, isOver: over } = useDroppable ({ - id: `slot:${ cat }:${ index }`, - data: { kind: 'slot', cat, index } }) + id: `slot:${ cat }`, + data: { kind: 'slot', cat } }) return (
  • @@ -162,12 +216,6 @@ const DropSlot = ({ cat, index }: { cat: Category, index: number }) => { } -const removeFromRoot = ( - roots: Tag[], - childId: number, -): Tag[] => roots.filter (t => t.id !== childId) - - type Props = { post: Post | null } @@ -189,15 +237,36 @@ export default (({ post }: Props) => { const overKind = e.over?.data.current?.kind - if (!(childId) || !(overKind)) + if (childId == null || !(overKind)) return switch (overKind) { case 'tag': const parentId: number | undefined = e.over?.data.current?.tagId - if (!(parentId) || childId === parentId) + 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) @@ -205,38 +274,33 @@ export default (({ post }: Props) => { if (!(child) || !(parent) || isDescendant (child, parentId)) return prev - const cat = child.category - const next: TagByCategory = { ...prev } - - if (fromParentId) - next[cat] = detachEdge (next[cat], fromParentId, childId) - else - next[cat] = removeFromRoot (next[cat], childId) - - next[cat] = addAsChild (next[cat], parentId, child) + toast ({ description: `《${ child.name }》を《${ parent.name }》の子タグに設定しました.` }) - return next + return attachChildOptimistic (prev, parentId, childId) }) - if (fromParentId) - { - 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') ?? '' } }) - break case 'slot': const cat: Category | undefined = e.over?.data.current?.cat - const index: number | undefined = e.over?.data.current?.index - if (!(cat) || index == null) + + if (fromParentId == null + || !(cat) + || cat !== findTag (tags, childId)?.category) + return + + try + { + await axios.delete ( + `${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`, + { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) + } + catch + { + toast ({ description: '管理者権限が必要です.' }) + return + } setTags (prev => { const child = findTag (prev, childId) @@ -245,18 +309,16 @@ export default (({ post }: Props) => { const next: TagByCategory = { ...prev } - if (fromParentId) + if (fromParentId != null) next[cat] = detachEdge (next[cat], fromParentId, childId) as any - next[cat] = insertRootAt (next[cat], index, child) + next[cat] = insertRootAt (next[cat], 0, child) + + next[cat].sort ((a: Tag, b: Tag) => a.name < b.name ? -1 : 1) return next }) - await axios.delete ( - `${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`, - { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) - break } } @@ -322,7 +384,7 @@ export default (({ post }: Props) => { <> {renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)} ))} - + ))} -- 2.34.1 From 4c7c41c99134aed6e81bbafde185c20257eeb6cd Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 21 Dec 2025 04:29:01 +0900 Subject: [PATCH 5/5] #184 --- backend/app/controllers/tags_controller.rb | 11 + .../components/DraggableDroppableTagRow.tsx | 2 +- frontend/src/components/TagDetailSidebar.tsx | 305 ++++++++---------- 3 files changed, 139 insertions(+), 179 deletions(-) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 6597a43..c9203ee 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -39,6 +39,17 @@ class TagsController < ApplicationController 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 def destroy diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index 9b20183..c12ab09 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -85,7 +85,7 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Pro setDropRef (node) }} style={style} - className={cn ('rounded select-none touch-none', over && 'ring-2 ring-offset-2')} + className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} {...attributes} {...listeners}> diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index ca4e23c..82e7d65 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -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 => { + 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 => { + 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) => { { + 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 = '' }}> - {CATEGORIES.map ((cat: Category) => cat in tags && ( + {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( {categoryNames[cat]} - {tags[cat].map (tag => ( - <> - {renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)} - ))} + {(tags[cat] ?? []).flatMap (tag => ( + renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))} -- 2.34.1