| @@ -52,9 +52,12 @@ class PostsController < ApplicationController | |||||
| viewed = current_user&.viewed?(post) || false | 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 | end | ||||
| # POST /posts | # POST /posts | ||||
| @@ -78,6 +81,7 @@ class PostsController < ApplicationController | |||||
| if post.save | if post.save | ||||
| post.resized_thumbnail! | post.resized_thumbnail! | ||||
| post.tags = Tag.normalise_tags(tag_names) | 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] } }), | render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), | ||||
| status: :created | status: :created | ||||
| else | else | ||||
| @@ -112,9 +116,11 @@ class PostsController < ApplicationController | |||||
| post = Post.find(params[:id].to_i) | post = Post.find(params[:id].to_i) | ||||
| tags = post.tags.where(category: 'nico').to_a + | tags = post.tags.where(category: 'nico').to_a + | ||||
| Tag.normalise_tags(tag_names, with_tagme: false) | 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:) | 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 | else | ||||
| render json: post.errors, status: :unprocessable_entity | render json: post.errors, status: :unprocessable_entity | ||||
| end | end | ||||
| @@ -143,4 +149,46 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| posts.distinct | posts.distinct | ||||
| end | 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 | end | ||||
| @@ -11,6 +11,14 @@ class Tag < ApplicationRecord | |||||
| dependent: :destroy | dependent: :destroy | ||||
| has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag | 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', | enum :category, { deerjikist: 'deerjikist', | ||||
| meme: 'meme', | meme: 'meme', | ||||
| character: 'character', | character: 'character', | ||||
| @@ -61,6 +69,29 @@ class Tag < ApplicationRecord | |||||
| tags.uniq | tags.uniq | ||||
| end | 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 | private | ||||
| def nico_tag_name_must_start_with_nico | def nico_tag_name_must_start_with_nico | ||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -10,7 +10,7 @@ | |||||
| # | # | ||||
| # It's strongly recommended that you check this file into your version control system. | # 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| | create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "name", null: false | t.string "name", null: false | ||||
| t.string "record_type", null: false | t.string "record_type", null: false | ||||
| @@ -1,6 +1,6 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
| import { useState } from 'react' | |||||
| import { useEffect, useState } from 'react' | |||||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | import PostFormTagsArea from '@/components/PostFormTagsArea' | ||||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | ||||
| @@ -10,7 +10,23 @@ import { API_BASE_URL } from '@/config' | |||||
| import type { FC } from 'react' | 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 | type Props = { post: Post | ||||
| onSave: (newPost: Post) => void } | onSave: (newPost: Post) => void } | ||||
| @@ -22,10 +38,7 @@ export default (({ post, onSave }: Props) => { | |||||
| const [originalCreatedFrom, setOriginalCreatedFrom] = | const [originalCreatedFrom, setOriginalCreatedFrom] = | ||||
| useState<string | null> (post.originalCreatedFrom) | useState<string | null> (post.originalCreatedFrom) | ||||
| const [title, setTitle] = useState (post.title) | const [title, setTitle] = useState (post.title) | ||||
| const [tags, setTags] = useState<string> (post.tags | |||||
| .filter (t => t.category !== 'nico') | |||||
| .map (t => t.name) | |||||
| .join (' ')) | |||||
| const [tags, setTags] = useState<string> ('') | |||||
| const handleSubmit = async () => { | const handleSubmit = async () => { | ||||
| const res = await axios.put ( | const res = await axios.put ( | ||||
| @@ -43,6 +56,10 @@ export default (({ post, onSave }: Props) => { | |||||
| originalCreatedBefore: data.originalCreatedBefore } as Post) | originalCreatedBefore: data.originalCreatedBefore } as Post) | ||||
| } | } | ||||
| useEffect (() => { | |||||
| setTags(tagsToStr (post.tags)) | |||||
| }, [post]) | |||||
| return ( | return ( | ||||
| <div className="max-w-xl pt-2 space-y-4"> | <div className="max-w-xl pt-2 space-y-4"> | ||||
| {/* タイトル */} | {/* タイトル */} | ||||
| @@ -7,12 +7,30 @@ import SubsectionTitle from '@/components/common/SubsectionTitle' | |||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| import { CATEGORIES } from '@/consts' | import { CATEGORIES } from '@/consts' | ||||
| import type { FC } from 'react' | |||||
| import type { FC, ReactNode } from 'react' | |||||
| import type { Category, Post, Tag } from '@/types' | import type { Category, Post, Tag } from '@/types' | ||||
| type TagByCategory = { [key in Category]: Tag[] } | type TagByCategory = { [key in Category]: Tag[] } | ||||
| const renderTagTree = ( | |||||
| tag: Tag, | |||||
| nestLevel: number, | |||||
| path: string, | |||||
| ): ReactNode[] => { | |||||
| const key = `${ path }-${ tag.id }` | |||||
| const self = ( | |||||
| <li key={key} className="mb-1"> | |||||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||||
| </li>) | |||||
| return [self, | |||||
| ...(tag.children?.flatMap (child => renderTagTree (child, nestLevel + 1, key)) ?? [])] | |||||
| } | |||||
| type Props = { post: Post | null } | type Props = { post: Post | null } | ||||
| @@ -54,10 +72,7 @@ export default (({ post }: Props) => { | |||||
| <div className="my-3" key={cat}> | <div className="my-3" key={cat}> | ||||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | ||||
| <ul> | <ul> | ||||
| {tags[cat].map (tag => ( | |||||
| <li key={tag.id} className="mb-1"> | |||||
| <TagLink tag={tag}/> | |||||
| </li>))} | |||||
| {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))} | |||||
| </ul> | </ul> | ||||
| </div>))} | </div>))} | ||||
| {post && ( | {post && ( | ||||
| @@ -8,6 +8,7 @@ import type { ComponentProps, FC, HTMLAttributes } from 'react' | |||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| type CommonProps = { tag: Tag | type CommonProps = { tag: Tag | ||||
| nestLevel?: number | |||||
| withWiki?: boolean | withWiki?: boolean | ||||
| withCount?: boolean } | withCount?: boolean } | ||||
| @@ -21,6 +22,7 @@ type Props = PropsWithLink | PropsWithoutLink | |||||
| export default (({ tag, | export default (({ tag, | ||||
| nestLevel = 0, | |||||
| linkFlg = true, | linkFlg = true, | ||||
| withWiki = true, | withWiki = true, | ||||
| withCount = true, | withCount = true, | ||||
| @@ -42,6 +44,12 @@ export default (({ tag, | |||||
| ? | ? | ||||
| </Link> | </Link> | ||||
| </span>)} | </span>)} | ||||
| {nestLevel > 0 && ( | |||||
| <span | |||||
| className="ml-1 mr-1" | |||||
| style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}> | |||||
| ↳ | |||||
| </span>)} | |||||
| {linkFlg | {linkFlg | ||||
| ? ( | ? ( | ||||
| <Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | <Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | ||||
| @@ -1,7 +1,7 @@ | |||||
| import React from 'react' | |||||
| import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' | import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' | ||||
| import type { ReactNode } from 'react' | |||||
| export type Category = typeof CATEGORIES[number] | export type Category = typeof CATEGORIES[number] | ||||
| export type Menu = MenuItem[] | export type Menu = MenuItem[] | ||||
| @@ -29,19 +29,19 @@ export type Post = { | |||||
| originalCreatedFrom: string | null | originalCreatedFrom: string | null | ||||
| originalCreatedBefore: string | null } | originalCreatedBefore: string | null } | ||||
| export type SubMenuItem = { | |||||
| component: React.ReactNode | |||||
| visible: boolean | |||||
| } | { | |||||
| name: string | |||||
| to: string | |||||
| visible?: boolean } | |||||
| export type SubMenuItem = | |||||
| | { component: ReactNode | |||||
| visible: boolean } | |||||
| | { name: string | |||||
| to: string | |||||
| visible?: boolean } | |||||
| export type Tag = { | export type Tag = { | ||||
| id: number | id: number | ||||
| name: string | name: string | ||||
| category: Category | category: Category | ||||
| postCount: number } | |||||
| postCount: number | |||||
| children?: Tag[] } | |||||
| export type User = { | export type User = { | ||||
| id: number | id: number | ||||