From fb9e708261faa30a0e9e2326b7afd82d79154f91 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 19 Dec 2025 01:37:08 +0900 Subject: [PATCH] #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 = {