| @@ -48,9 +48,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 | ||||
| @@ -73,7 +76,9 @@ class PostsController < ApplicationController | |||||
| post.thumbnail.attach(thumbnail) | post.thumbnail.attach(thumbnail) | ||||
| if post.save | if post.save | ||||
| post.resized_thumbnail! | post.resized_thumbnail! | ||||
| sync_post_tags!(post, Tag.normalise_tags(tag_names)) | |||||
| tags = Tag.normalise_tags(tag_names) | |||||
| tags = Tag.expand_parent_tags(tags) | |||||
| sync_post_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 | ||||
| @@ -107,11 +112,13 @@ class PostsController < ApplicationController | |||||
| post = Post.find(params[:id].to_i) | post = Post.find(params[:id].to_i) | ||||
| if post.update(title:, original_created_from:, original_created_before:) | if post.update(title:, original_created_from:, original_created_before:) | ||||
| sync_post_tags!(post, | |||||
| (post.tags.where(category: 'nico').to_a + | |||||
| Tag.normalise_tags(tag_names))) | |||||
| render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), | |||||
| status: :ok | |||||
| tags = post.tags.where(category: 'nico').to_a + | |||||
| Tag.normalise_tags(tag_names, with_tagme: false) | |||||
| tags = Tag.expand_parent_tags(tags) | |||||
| sync_post_tags!(post, tags) | |||||
| 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 | ||||
| @@ -168,4 +175,46 @@ class PostsController < ApplicationController | |||||
| pt.discard_by!(current_user) | pt.discard_by!(current_user) | ||||
| end | end | ||||
| 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 | ||||
| @@ -6,12 +6,15 @@ class UsersController < ApplicationController | |||||
| end | end | ||||
| def verify | def verify | ||||
| ip_bin = IPAddr.new(request.remote_ip).hton | |||||
| ip_address = IpAddress.find_or_create_by!(ip_address: ip_bin) | |||||
| user = User.find_by(inheritance_code: params[:code]) | user = User.find_by(inheritance_code: params[:code]) | ||||
| render json: if user | |||||
| { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } | |||||
| else | |||||
| { valid: false } | |||||
| end | |||||
| return render json: { valid: false } unless user | |||||
| UserIp.find_or_create_by!(user:, ip_address:) | |||||
| render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } | |||||
| end | end | ||||
| def renew | def renew | ||||
| @@ -13,6 +13,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', | ||||
| @@ -59,10 +67,33 @@ class Tag < ApplicationRecord | |||||
| end | end | ||||
| end | end | ||||
| end | end | ||||
| tags << Tag.tagme if with_tagme && tags.size < 20 && tags.none?(Tag.tagme) | |||||
| tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) | |||||
| 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, uniqueness: { scope: :parent_tag_id } | |||||
| 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 | |||||
| @@ -8,7 +8,6 @@ class User < ApplicationRecord | |||||
| has_many :posts | has_many :posts | ||||
| has_many :settings | has_many :settings | ||||
| has_many :ip_addresses | |||||
| has_many :user_ips, dependent: :destroy | has_many :user_ips, dependent: :destroy | ||||
| has_many :ip_addresses, through: :user_ips | has_many :ip_addresses, through: :user_ips | ||||
| has_many :user_post_views, dependent: :destroy | has_many :user_post_views, dependent: :destroy | ||||
| @@ -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 | |||||
| @@ -0,0 +1,5 @@ | |||||
| class RenameIpAdressColumnToIpAddresses < ActiveRecord::Migration[8.0] | |||||
| def change | |||||
| rename_column :ip_addresses, :ip_adress, :ip_address | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,27 @@ | |||||
| class AddUniqueIndexToTagImplications < ActiveRecord::Migration[8.0] | |||||
| def up | |||||
| execute <<~SQL | |||||
| DELETE | |||||
| ti1 | |||||
| FROM | |||||
| tag_implications ti1 | |||||
| INNER JOIN | |||||
| tag_implications ti2 | |||||
| ON | |||||
| ti1.tag_id = ti2.tag_id | |||||
| AND ti1.parent_tag_id = ti2.parent_tag_id | |||||
| AND ti1.id > ti2.id | |||||
| ; | |||||
| SQL | |||||
| add_index :tag_implications, [:tag_id, :parent_tag_id], | |||||
| unique: true, | |||||
| name: 'index_tag_implications_on_tag_id_and_parent_tag_id' | |||||
| end | |||||
| def down | |||||
| # NOTE: 重複削除は復元されなぃ. | |||||
| remove_index :tag_implications, | |||||
| name: 'index_tag_implications_on_tag_id_and_parent_tag_id' | |||||
| end | |||||
| end | |||||
| @@ -1,12 +0,0 @@ | |||||
| namespace :nico do | |||||
| desc 'ニコタグ連携' | |||||
| task link: :environment do | |||||
| Post.find_each do |post| | |||||
| tags = post.tags.where(category: 'nico') | |||||
| tags.each do |tag| | |||||
| post.tags.concat(tag.linked_tags) if tag.linked_tags.present? | |||||
| end | |||||
| post.tags = post.tags.to_a.uniq | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -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,32 @@ 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 | |||||
| ?.sort ((a, b) => a.name < b.name ? -1 : 1) | |||||
| .flatMap (child => renderTagTree (child, nestLevel + 1, key)) ?? [])] | |||||
| } | |||||
| type Props = { post: Post | null } | type Props = { post: Post | null } | ||||
| @@ -54,10 +74,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 | ||||