diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index b6e7d5d..81a1c0e 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -52,9 +52,12 @@ class PostsController < ApplicationController viewed = current_user&.viewed?(post) || false - render json: (post - .as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) - .merge(related: post.related(limit: 20), viewed:)) + json = post.as_json + json['tags'] = build_tag_tree_for(post.tags) + json['related'] = post.related(limit: 20) + json['viewed'] = viewed + + render json: end # POST /posts @@ -78,6 +81,7 @@ class PostsController < ApplicationController if post.save post.resized_thumbnail! post.tags = Tag.normalise_tags(tag_names) + post.tags = Tag.expand_parent_tags(post.tags) render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), status: :created else @@ -112,9 +116,11 @@ class PostsController < ApplicationController post = Post.find(params[:id].to_i) tags = post.tags.where(category: 'nico').to_a + Tag.normalise_tags(tag_names, with_tagme: false) + tags = Tag.expand_parent_tags(tags) if post.update(title:, tags:, original_created_from:, original_created_before:) - render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), - status: :ok + json = post.as_json + json['tags'] = build_tag_tree_for(post.tags) + render json:, status: :ok else render json: post.errors, status: :unprocessable_entity end @@ -143,4 +149,46 @@ class PostsController < ApplicationController end posts.distinct end + + def build_tag_tree_for tags + tags = tags.to_a + tag_ids = tags.map(&:id) + + implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids) + + children_ids_by_parent = Hash.new { |h, k| h[k] = [] } + implications.each do |imp| + children_ids_by_parent[imp.parent_tag_id] << imp.tag_id + end + + child_ids = children_ids_by_parent.values.flatten.uniq + + root_ids = tag_ids - child_ids + + tags_by_id = tags.index_by(&:id) + + memo = { } + + build_node = -> tag_id, path do + tag = tags_by_id[tag_id] + return nil unless tag + + if path.include?(tag_id) + return tag.as_json(only: [:id, :name, :category, :post_count]).merge(children: []) + end + + if memo.key?(tag_id) + return memo[tag_id] + end + + new_path = path + [tag_id] + child_ids = children_ids_by_parent[tag_id] || [] + + children = child_ids.filter_map { |cid| build_node.(cid, new_path) } + + memo[tag_id] = tag.as_json(only: [:id, :name, :category, :post_count]).merge(children:) + end + + root_ids.filter_map { |id| build_node.call(id, []) } + end end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index e80baff..8a505e2 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -11,6 +11,14 @@ class Tag < ApplicationRecord dependent: :destroy has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag + has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy + has_many :children, through: :tag_implications, source: :tag + + has_many :reversed_tag_implications, class_name: 'TagImplication', + foreign_key: :tag_id, + dependent: :destroy + has_many :parents, through: :reversed_tag_implications, source: :parent_tag + enum :category, { deerjikist: 'deerjikist', meme: 'meme', character: 'character', @@ -61,6 +69,29 @@ class Tag < ApplicationRecord tags.uniq end + def self.expand_parent_tags tags + return [] if tags.blank? + + seen = Set.new + result = [] + stack = tags.compact.dup + + until stack.empty? + tag = stack.pop + next unless tag + + tag.parents.each do |parent| + next if seen.include?(parent.id) + + seen << parent.id + result << parent + stack << parent + end + end + + (result + tags).uniq { |t| t.id } + end + private def nico_tag_name_must_start_with_nico diff --git a/backend/app/models/tag_implication.rb b/backend/app/models/tag_implication.rb new file mode 100644 index 0000000..68f4083 --- /dev/null +++ b/backend/app/models/tag_implication.rb @@ -0,0 +1,17 @@ +class TagImplication < ApplicationRecord + belongs_to :tag, class_name: 'Tag' + belongs_to :parent_tag, class_name: 'Tag' + + validates :tag_id, presence: true + validates :parent_tag_id, presence: true + + validate :parent_tag_mustnt_be_itself + + private + + def parent_tag_mustnt_be_itself + if parent_tag == tag + errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.' + end + end +end diff --git a/backend/db/migrate/20251009222200_create_tag_implications.rb b/backend/db/migrate/20251009222200_create_tag_implications.rb new file mode 100644 index 0000000..ea8df1a --- /dev/null +++ b/backend/db/migrate/20251009222200_create_tag_implications.rb @@ -0,0 +1,9 @@ +class CreateTagImplications < ActiveRecord::Migration[8.0] + def change + create_table :tag_implications do |t| + t.references :tag, null: false, foreign_key: { to_table: :tags } + t.references :parent_tag, null: false, foreign_key: { to_table: :tags } + t.timestamps + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index b8210d8..19ec4f6 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_11_26_231500) do +ActiveRecord::Schema[8.0].define(version: 2025_10_09_222200) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 60a4318..38b03b4 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -1,6 +1,6 @@ import axios from 'axios' import toCamel from 'camelcase-keys' -import { useState } from 'react' +import { useEffect, useState } from 'react' import PostFormTagsArea from '@/components/PostFormTagsArea' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' @@ -10,7 +10,23 @@ import { API_BASE_URL } from '@/config' import type { FC } from 'react' -import type { Post } from '@/types' +import type { Post, Tag } from '@/types' + + +const tagsToStr = (tags: Tag[]): string => { + const result: Tag[] = [] + + const walk = (tag: Tag) => { + const { children, ...rest } = tag + result.push (rest) + children?.forEach (walk) + } + + tags.filter (t => t.category !== 'nico').forEach (walk) + + return [...(new Set (result.map (t => t.name)))].join (' ') +} + type Props = { post: Post onSave: (newPost: Post) => void } @@ -22,10 +38,7 @@ export default (({ post, onSave }: Props) => { const [originalCreatedFrom, setOriginalCreatedFrom] = useState (post.originalCreatedFrom) const [title, setTitle] = useState (post.title) - const [tags, setTags] = useState (post.tags - .filter (t => t.category !== 'nico') - .map (t => t.name) - .join (' ')) + const [tags, setTags] = useState ('') const handleSubmit = async () => { const res = await axios.put ( @@ -43,6 +56,10 @@ export default (({ post, onSave }: Props) => { originalCreatedBefore: data.originalCreatedBefore } as Post) } + useEffect (() => { + setTags(tagsToStr (post.tags)) + }, [post]) + return (
{/* タイトル */} diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 6e2e2dd..fcbfa97 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -7,12 +7,30 @@ import SubsectionTitle from '@/components/common/SubsectionTitle' import SidebarComponent from '@/components/layout/SidebarComponent' import { CATEGORIES } from '@/consts' -import type { FC } from 'react' +import type { FC, ReactNode } from 'react' import type { Category, Post, Tag } from '@/types' type TagByCategory = { [key in Category]: Tag[] } + +const renderTagTree = ( + tag: Tag, + nestLevel: number, + path: string, + ): ReactNode[] => { + const key = `${ path }-${ tag.id }` + + const self = ( +
  • + +
  • ) + + return [self, + ...(tag.children?.flatMap (child => renderTagTree (child, nestLevel + 1, key)) ?? [])] +} + + type Props = { post: Post | null } @@ -54,10 +72,7 @@ export default (({ post }: Props) => {
    {categoryNames[cat]}
      - {tags[cat].map (tag => ( -
    • - -
    • ))} + {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
    ))} {post && ( diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 0734d4d..a77ca5f 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -8,6 +8,7 @@ import type { ComponentProps, FC, HTMLAttributes } from 'react' import type { Tag } from '@/types' type CommonProps = { tag: Tag + nestLevel?: number withWiki?: boolean withCount?: boolean } @@ -21,6 +22,7 @@ type Props = PropsWithLink | PropsWithoutLink export default (({ tag, + nestLevel = 0, linkFlg = true, withWiki = true, withCount = true, @@ -42,6 +44,12 @@ export default (({ tag, ? )} + {nestLevel > 0 && ( + + ↳ + )} {linkFlg ? (