Browse Source

Merge remote-tracking branch 'origin/main' into feature/084

feature/084
みてるぞ 3 days ago
parent
commit
96e2e3decf
13 changed files with 219 additions and 49 deletions
  1. +58
    -9
      backend/app/controllers/posts_controller.rb
  2. +8
    -5
      backend/app/controllers/users_controller.rb
  3. +32
    -1
      backend/app/models/tag.rb
  4. +17
    -0
      backend/app/models/tag_implication.rb
  5. +0
    -1
      backend/app/models/user.rb
  6. +9
    -0
      backend/db/migrate/20251009222200_create_tag_implications.rb
  7. +5
    -0
      backend/db/migrate/20251126231500_rename_ip_adress_column_to_ip_addresses.rb
  8. +27
    -0
      backend/db/migrate/20251210123200_add_unique_index_to_tag_implications.rb
  9. +0
    -12
      backend/lib/tasks/link_nico.rake
  10. +23
    -6
      frontend/src/components/PostEditForm.tsx
  11. +22
    -5
      frontend/src/components/TagDetailSidebar.tsx
  12. +8
    -0
      frontend/src/components/TagLink.tsx
  13. +10
    -10
      frontend/src/types.ts

+ 58
- 9
backend/app/controllers/posts_controller.rb View File

@@ -48,9 +48,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
@@ -73,7 +76,9 @@ class PostsController < ApplicationController
post.thumbnail.attach(thumbnail)
if post.save
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] } }),
status: :created
else
@@ -107,11 +112,13 @@ class PostsController < ApplicationController

post = Post.find(params[:id].to_i)
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
render json: post.errors, status: :unprocessable_entity
end
@@ -168,4 +175,46 @@ class PostsController < ApplicationController
pt.discard_by!(current_user)
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

+ 8
- 5
backend/app/controllers/users_controller.rb View File

@@ -6,12 +6,15 @@ class UsersController < ApplicationController
end

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])
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

def renew


+ 32
- 1
backend/app/models/tag.rb View File

@@ -13,6 +13,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',
@@ -59,10 +67,33 @@ class Tag < ApplicationRecord
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
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


+ 17
- 0
backend/app/models/tag_implication.rb View File

@@ -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

+ 0
- 1
backend/app/models/user.rb View File

@@ -8,7 +8,6 @@ class User < ApplicationRecord

has_many :posts
has_many :settings
has_many :ip_addresses
has_many :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy


+ 9
- 0
backend/db/migrate/20251009222200_create_tag_implications.rb View File

@@ -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

+ 5
- 0
backend/db/migrate/20251126231500_rename_ip_adress_column_to_ip_addresses.rb View File

@@ -0,0 +1,5 @@
class RenameIpAdressColumnToIpAddresses < ActiveRecord::Migration[8.0]
def change
rename_column :ip_addresses, :ip_adress, :ip_address
end
end

+ 27
- 0
backend/db/migrate/20251210123200_add_unique_index_to_tag_implications.rb View File

@@ -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

+ 0
- 12
backend/lib/tasks/link_nico.rake View File

@@ -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

+ 23
- 6
frontend/src/components/PostEditForm.tsx View File

@@ -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<string | null> (post.originalCreatedFrom)
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 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 (
<div className="max-w-xl pt-2 space-y-4">
{/* タイトル */}


+ 22
- 5
frontend/src/components/TagDetailSidebar.tsx View File

@@ -7,12 +7,32 @@ 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 = (
<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 }


@@ -54,10 +74,7 @@ export default (({ post }: Props) => {
<div className="my-3" key={cat}>
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
<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>
</div>))}
{post && (


+ 8
- 0
frontend/src/components/TagLink.tsx View File

@@ -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,
?
</Link>
</span>)}
{nestLevel > 0 && (
<span
className="ml-1 mr-1"
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)}
{linkFlg
? (
<Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}


+ 10
- 10
frontend/src/types.ts View File

@@ -1,7 +1,7 @@
import React from 'react'

import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts'

import type { ReactNode } from 'react'

export type Category = typeof CATEGORIES[number]

export type Menu = MenuItem[]
@@ -29,19 +29,19 @@ export type Post = {
originalCreatedFrom: 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 = {
id: number
name: string
category: Category
postCount: number }
postCount: number
children?: Tag[] }

export type User = {
id: number


Loading…
Cancel
Save