From 4c7c41c99134aed6e81bbafde185c20257eeb6cd Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 21 Dec 2025 04:29:01 +0900 Subject: [PATCH] #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)))}