feat: 上位タグ(#64) (#173)
#64 おそらく完成 Merge remote-tracking branch 'origin/main' into feature/064 #64 バックエンドぼちぼち Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: #173
This commit was merged in pull request #173.
This commit is contained in:
@@ -52,9 +52,12 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
viewed = current_user&.viewed?(post) || false
|
viewed = current_user&.viewed?(post) || false
|
||||||
|
|
||||||
render json: (post
|
json = post.as_json
|
||||||
.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } })
|
json['tags'] = build_tag_tree_for(post.tags)
|
||||||
.merge(related: post.related(limit: 20), viewed:))
|
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] } }),
|
json = post.as_json
|
||||||
status: :ok
|
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
|
||||||
Generated
+1
-1
@@ -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
|
const [tags, setTags] = useState<string> ('')
|
||||||
.filter (t => t.category !== 'nico')
|
|
||||||
.map (t => t.name)
|
|
||||||
.join (' '))
|
|
||||||
|
|
||||||
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 => (
|
{tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
|
||||||
<li key={tag.id} className="mb-1">
|
|
||||||
<TagLink tag={tag}/>
|
|
||||||
</li>))}
|
|
||||||
</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 () }`}
|
||||||
|
|||||||
+10
-10
@@ -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 = {
|
export type SubMenuItem =
|
||||||
component: React.ReactNode
|
| { component: ReactNode
|
||||||
visible: boolean
|
visible: boolean }
|
||||||
} | {
|
| { name: string
|
||||||
name: string
|
to: string
|
||||||
to: string
|
visible?: boolean }
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user