Browse Source

Merge branch 'main' into feature/100

pull/153/head
みてるぞ 2 weeks ago
parent
commit
55653ecccd
29 changed files with 957 additions and 236 deletions
  1. +2
    -0
      backend/Gemfile
  2. +3
    -0
      backend/Gemfile.lock
  3. +128
    -13
      backend/app/controllers/posts_controller.rb
  4. +8
    -5
      backend/app/controllers/users_controller.rb
  5. +7
    -5
      backend/app/models/post.rb
  6. +18
    -0
      backend/app/models/post_tag.rb
  7. +38
    -5
      backend/app/models/tag.rb
  8. +17
    -0
      backend/app/models/tag_implication.rb
  9. +0
    -1
      backend/app/models/user.rb
  10. +1
    -0
      backend/config/routes.rb
  11. +9
    -0
      backend/db/migrate/20251009222200_create_tag_implications.rb
  12. +36
    -0
      backend/db/migrate/20251011200300_add_discard_to_post_tags.rb
  13. +5
    -0
      backend/db/migrate/20251126231500_rename_ip_adress_column_to_ip_addresses.rb
  14. +27
    -0
      backend/db/migrate/20251210123200_add_unique_index_to_tag_implications.rb
  15. +21
    -2
      backend/db/schema.rb
  16. +0
    -12
      backend/lib/tasks/link_nico.rake
  17. +69
    -35
      backend/lib/tasks/sync_nico.rake
  18. +43
    -0
      frontend/package-lock.json
  19. +1
    -0
      frontend/package.json
  20. +2
    -3
      frontend/scripts/generate-sitemap.js
  21. +2
    -0
      frontend/src/App.tsx
  22. +23
    -6
      frontend/src/components/PostEditForm.tsx
  23. +82
    -54
      frontend/src/components/TagDetailSidebar.tsx
  24. +8
    -0
      frontend/src/components/TagLink.tsx
  25. +56
    -32
      frontend/src/components/TagSidebar.tsx
  26. +165
    -53
      frontend/src/components/TopNav.tsx
  27. +79
    -0
      frontend/src/components/common/Pagination.tsx
  28. +90
    -0
      frontend/src/pages/posts/PostHistoryPage.tsx
  29. +17
    -10
      frontend/src/types.ts

+ 2
- 0
backend/Gemfile View File

@@ -63,3 +63,5 @@ gem 'diff-lcs'
gem 'dotenv-rails'

gem 'whenever', require: false

gem 'discard'

+ 3
- 0
backend/Gemfile.lock View File

@@ -90,6 +90,8 @@ GEM
crass (1.0.6)
date (3.4.1)
diff-lcs (1.6.2)
discard (1.4.0)
activerecord (>= 4.2, < 9.0)
dotenv (3.1.8)
dotenv-rails (3.1.8)
dotenv (= 3.1.8)
@@ -420,6 +422,7 @@ DEPENDENCIES
bootsnap
brakeman
diff-lcs
discard
dotenv-rails
gollum
image_processing (~> 1.14)


+ 128
- 13
backend/app/controllers/posts_controller.rb View File

@@ -1,8 +1,6 @@
require 'open-uri'
require 'nokogiri'


class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)

# GET /posts
def index
limit = params[:limit].presence&.to_i
@@ -52,9 +50,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
@@ -77,7 +78,9 @@ class PostsController < ApplicationController
post.thumbnail.attach(thumbnail)
if post.save
post.resized_thumbnail!
post.tags = 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
@@ -110,10 +113,14 @@ class PostsController < ApplicationController
original_created_before = params[:original_created_before]

post = Post.find(params[:id].to_i)
tags = post.tags.where(category: 'nico').to_a + Tag.normalise_tags(tag_names)
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
if post.update(title:, original_created_from:, original_created_before:)
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
@@ -123,12 +130,54 @@ class PostsController < ApplicationController
def destroy
end

def changes
id = params[:id]
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i

page = 1 if page < 1
limit = 1 if limit < 1

offset = (page - 1) * limit

pts = PostTag.with_discarded
pts = pts.where(post_id: id) if id.present?
pts = pts.includes(:post, :tag, :created_user, :deleted_user)

events = []
pts.each do |pt|
events << Event.new(
post: pt.post,
tag: pt.tag,
user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
change_type: 'add',
timestamp: pt.created_at)

if pt.discarded_at
events << Event.new(
post: pt.post,
tag: pt.tag,
user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
change_type: 'remove',
timestamp: pt.discarded_at)
end
end
events.sort_by!(&:timestamp)
events.reverse!

render json: { changes: events.slice(offset, limit).as_json, count: events.size }
end

private

def filtered_posts
tag_names = params[:tags]&.split(' ')
match_type = params[:match]
tag_names.present? ? filter_posts_by_tags(tag_names, match_type) : Post.all
if tag_names.present?
filter_posts_by_tags(tag_names, match_type)
else
Post.all
end
end

def filter_posts_by_tags tag_names, match_type
@@ -142,4 +191,70 @@ class PostsController < ApplicationController
end
posts.distinct
end

def sync_post_tags! post, desired_tags
desired_tags.each do |t|
t.save! if t.new_record?
end

desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set

to_add = desired_ids - current_ids
to_remove = current_ids - desired_ids

Tag.where(id: to_add).find_each do |tag|
begin
PostTag.create!(post:, tag:, created_user: current_user)
rescue ActiveRecord::RecordNotUnique
;
end
end

PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
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


+ 7
- 5
backend/app/models/post.rb View File

@@ -1,11 +1,13 @@
require 'mini_magick'


class Post < ApplicationRecord
require 'mini_magick'

belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy
has_many :tags, through: :post_tags

has_many :post_tags, dependent: :destroy, inverse_of: :post
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags
has_many :user_post_views, dependent: :destroy
has_many :post_similarities_as_post,
class_name: 'PostSimilarity',


+ 18
- 0
backend/app/models/post_tag.rb View File

@@ -1,7 +1,25 @@
class PostTag < ApplicationRecord
include Discard::Model

belongs_to :post
belongs_to :tag, counter_cache: :post_count
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true

validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: {
scope: :tag_id,
conditions: -> { where(discarded_at: nil) } }

def discard_by! deleted_user
return self if discarded?

transaction do
update!(discarded_at: Time.current, deleted_user:)
Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)')
end

self
end
end

+ 38
- 5
backend/app/models/tag.rb View File

@@ -1,6 +1,8 @@
class Tag < ApplicationRecord
has_many :post_tags, dependent: :destroy
has_many :posts, through: :post_tags
has_many :post_tags, dependent: :delete_all, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :posts, through: :active_post_tags
has_many :tag_aliases, dependent: :destroy

has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
@@ -11,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',
@@ -35,13 +45,13 @@ class Tag < ApplicationRecord
'meta:' => 'meta' }.freeze

def self.tagme
@tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag|
@tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag|
tag.category = 'meta'
end
end

def self.bot
@bot ||= Tag.find_or_initialize_by(name: 'bot操作') do |tag|
@bot ||= Tag.find_or_create_by!(name: 'bot操作') do |tag|
tag.category = 'meta'
end
end
@@ -57,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


+ 1
- 0
backend/config/routes.rb View File

@@ -4,6 +4,7 @@ Rails.application.routes.draw do
get 'tags/autocomplete', to: 'tags#autocomplete'
get 'tags/name/:name', to: 'tags#show_by_name'
get 'posts/random', to: 'posts#random'
get 'posts/changes', to: 'posts#changes'
post 'posts/:id/viewed', to: 'posts#viewed'
delete 'posts/:id/viewed', to: 'posts#unviewed'
get 'preview/title', to: 'preview#title'


+ 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

+ 36
- 0
backend/db/migrate/20251011200300_add_discard_to_post_tags.rb View File

@@ -0,0 +1,36 @@
class AddDiscardToPostTags < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
pt1
FROM
post_tags pt1
INNER JOIN
post_tags pt2
ON
pt1.post_id = pt2.post_id
AND pt1.tag_id = pt2.tag_id
AND pt1.id > pt2.id
;
SQL

add_column :post_tags, :discarded_at, :datetime
add_index :post_tags, :discarded_at

add_column :post_tags, :is_active, :boolean,
as: 'discarded_at IS NULL', stored: true

add_column :post_tags, :active_unique_key, :string,
as: "CASE WHEN discarded_at IS NULL THEN CONCAT(post_id, ':', tag_id) ELSE NULL END",
stored: true

add_index :post_tags, :active_unique_key, unique: true, name: 'idx_post_tags_active_unique'

add_index :post_tags, [:post_id, :discarded_at]
add_index :post_tags, [:tag_id, :discarded_at]
end

def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
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

+ 21
- 2
backend/db/schema.rb View File

@@ -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_09_09_075500) do
ActiveRecord::Schema[8.0].define(version: 2025_12_10_123200) 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
@@ -40,7 +40,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_09_075500) do
end

create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_adress", limit: 16, null: false
t.binary "ip_address", limit: 16, null: false
t.boolean "banned", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -70,9 +70,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_09_075500) do
t.bigint "deleted_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "is_active", type: :boolean, as: "(`discarded_at` is null)", stored: true
t.virtual "active_unique_key", type: :string, as: "(case when (`discarded_at` is null) then concat(`post_id`,_utf8mb4':',`tag_id`) else NULL end)", stored: true
t.index ["active_unique_key"], name: "idx_post_tags_active_unique", unique: true
t.index ["created_user_id"], name: "index_post_tags_on_created_user_id"
t.index ["deleted_user_id"], name: "index_post_tags_on_deleted_user_id"
t.index ["discarded_at"], name: "index_post_tags_on_discarded_at"
t.index ["post_id", "discarded_at"], name: "index_post_tags_on_post_id_and_discarded_at"
t.index ["post_id"], name: "index_post_tags_on_post_id"
t.index ["tag_id", "discarded_at"], name: "index_post_tags_on_tag_id_and_discarded_at"
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end

@@ -107,6 +114,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_09_075500) do
t.index ["tag_id"], name: "index_tag_aliases_on_tag_id"
end

create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["parent_tag_id"], name: "index_tag_implications_on_parent_tag_id"
t.index ["tag_id", "parent_tag_id"], name: "index_tag_implications_on_tag_id_and_parent_tag_id", unique: true
t.index ["tag_id"], name: "index_tag_implications_on_tag_id"
end

create_table "tag_similarities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.bigint "target_tag_id", null: false
@@ -176,6 +193,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_09_075500) do
add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users"
add_foreign_key "tag_aliases", "tags"
add_foreign_key "tag_implications", "tags"
add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "user_ips", "ip_addresses"


+ 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

+ 69
- 35
backend/lib/tasks/sync_nico.rake View File

@@ -5,12 +5,32 @@ namespace :nico do
require 'open-uri'
require 'nokogiri'

fetch_thumbnail = -> url {
fetch_thumbnail = -> url do
html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read
doc = Nokogiri::HTML(html)

doc.at('meta[name="thumbnail"]')&.[]('content').presence
}
end

def sync_post_tags! post, desired_tag_ids
desired_ids = desired_tag_ids.compact.to_set
current_ids = post.tags.pluck(:id).to_set

to_add = desired_ids - current_ids
to_remove = current_ids - desired_ids

Tag.where(id: to_add.to_a).find_each do |tag|
begin
PostTag.create!(post:, tag:)
rescue ActiveRecord::RecordNotUnique
;
end
end

PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
end
end

mysql_user = ENV['MYSQL_USER']
mysql_pass = ENV['MYSQL_PASS']
@@ -19,43 +39,57 @@ namespace :nico do
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
'python3', "#{ nizika_nico_path }/get_videos.py")

if status.success?
data = JSON.parse(stdout)
data.each do |datum|
post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post|
post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)}
}
unless post
title = datum['title']
url = "https://www.nicovideo.jp/watch/#{ datum['code'] }"
thumbnail_base = fetch_thumbnail.(url) || '' rescue ''
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil)
if thumbnail_base.present?
post.thumbnail.attach(
io: URI.open(thumbnail_base),
filename: File.basename(URI.parse(thumbnail_base).path),
content_type: 'image/jpeg')
end
post.save!
post.resized_thumbnail!
abort unless status.success?

data = JSON.parse(stdout)
data.each do |datum|
post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post|
post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)}
}
unless post
title = datum['title']
url = "https://www.nicovideo.jp/watch/#{ datum['code'] }"
thumbnail_base = fetch_thumbnail.(url) || '' rescue ''
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil)
if thumbnail_base.present?
post.thumbnail.attach(
io: URI.open(thumbnail_base),
filename: File.basename(URI.parse(thumbnail_base).path),
content_type: 'image/jpeg')
end
post.save!
post.resized_thumbnail!
sync_post_tags!(post, [Tag.tagme.id])
end

kept_tags = post.tags.reload
kept_non_nico_ids = kept_tags.where.not(category: 'nico').pluck(:id).to_set

current_tags = post.tags.where(category: 'nico').pluck(:name).sort
new_tags = datum['tags'].map { |tag| "nico:#{ tag }" }.sort
if current_tags != new_tags
post.tags.destroy(post.tags.where(name: current_tags))
tags_to_add = []
new_tags.each do |name|
tag = Tag.find_or_initialize_by(name:) do |t|
t.category = 'nico'
end
tags_to_add.concat([tag] + tag.linked_tags)
end
tags_to_add << Tag.tagme if post.tags.size < 20
tags_to_add << Tag.bot
post.tags = (post.tags + tags_to_add).uniq
desired_nico_ids = []
desired_non_nico_ids = []
datum['tags'].each do |raw|
name = "nico:#{ raw }"
tag = Tag.find_or_initialize_by(name:) do |t|
t.category = 'nico'
end
tag.save! if tag.new_record?
desired_nico_ids << tag.id
unless tag.in?(kept_tags)
desired_non_nico_ids.concat(tag.linked_tags.pluck(:id))
desired_nico_ids.concat(tag.linked_tags.pluck(:id))
end
end
desired_nico_ids.uniq!

desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids
desired_non_nico_ids.concat(kept_non_nico_ids.to_a)
desired_non_nico_ids.uniq!
if kept_non_nico_ids.to_set != desired_non_nico_ids.to_set
desired_all_ids << Tag.bot.id
end
desired_all_ids.uniq!

sync_post_tags!(post, desired_all_ids)
end
end
end

+ 43
- 0
frontend/package-lock.json View File

@@ -17,6 +17,7 @@
"camelcase-keys": "^9.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"humps": "^2.0.1",
"lucide-react": "^0.511.0",
"markdown-it": "^14.1.0",
@@ -3573,6 +3574,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5216,6 +5244,21 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",


+ 1
- 0
frontend/package.json View File

@@ -19,6 +19,7 @@
"camelcase-keys": "^9.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"humps": "^2.0.1",
"lucide-react": "^0.511.0",
"markdown-it": "^14.1.0",


+ 2
- 3
frontend/scripts/generate-sitemap.js View File

@@ -19,7 +19,6 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`,
{ params: { ...(tagName && { tags: tagName,
match: 'all',
limit: '20' }) } })).data.posts
const fetchPostIds = async () => (await fetchPosts ()).map (post => post.id)

const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data
const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name)
@@ -33,7 +32,7 @@ const createPostListOutlet = async tagName => `
<div class="flex gap-4"><a href="#" class="font-bold">広場</a></div>
<div class="mt-2">
<div class="flex flex-wrap gap-6 p-4">
${ (await fetchPosts (tagName)).map (post => `
${ (await fetchPosts (tagName)).slice (0, 20).map (post => `
<a class="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
href="/posts/${ post.id }">
<img alt="${ post.title }"
@@ -42,7 +41,7 @@ const createPostListOutlet = async tagName => `
fetchpriority="high"
decoding="async"
class="object-none w-full h-full"
src="${ post.url }" />
src="${ post.thumbnail }" />
</a>`).join ('') }
</div>
</div>


+ 2
- 0
frontend/src/App.tsx View File

@@ -9,6 +9,7 @@ import { API_BASE_URL } from '@/config'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostListPage from '@/pages/posts/PostListPage'
import PostNewPage from '@/pages/posts/PostNewPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
@@ -79,6 +80,7 @@ export default (() => {
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/:id" element={<PostDetailPage user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>


+ 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">
{/* タイトル */}


+ 82
- 54
frontend/src/components/TagDetailSidebar.tsx View File

@@ -1,3 +1,4 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'

import TagLink from '@/components/TagLink'
@@ -7,12 +8,37 @@ 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 = (
<motion.li
key={key}
layout
transition={{ duration: .2, ease: 'easeOut' }}
className="mb-1">
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.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 }


@@ -50,58 +76,60 @@ export default (({ post }: Props) => {
return (
<SidebarComponent>
<TagSearch/>
{CATEGORIES.map ((cat: Category) => cat in tags && (
<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>))}
</ul>
</div>))}
{post && (
<div>
<SectionTitle>情報</SectionTitle>
<ul>
<li>Id.: {post.id}</li>
{/* TODO: uploadedUser の取得を対応したらコメント外す */}
{/*
<li>
<>耕作者: </>
{post.uploadedUser
? (
<Link to={`/users/${ post.uploadedUser.id }`}>
{post.uploadedUser.name || '名もなきニジラー'}
</Link>)
: 'bot操作'}
</li>
*/}
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li>
<li>
<>リンク: </>
<a
className="break-all"
href={post.url}
target="_blank"
rel="noopener noreferrer nofollow">
{post.url}
</a>
</li>
<li>
{/* TODO: 表示形式きしょすぎるので何とかする */}
<>オリジナルの投稿日時: </>
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
? '不明'
: (
<>
{post.originalCreatedFrom
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
{post.originalCreatedBefore
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
</>)}
</li>
</ul>
</div>)}
<motion.div key={post?.id ?? 0} layout>
{CATEGORIES.map ((cat: Category) => cat in tags && (
<motion.div layout className="my-3" key={cat}>
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>

<motion.ul layout>
<AnimatePresence initial={false}>
{tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
</AnimatePresence>
</motion.ul>
</motion.div>))}
{post && (
<div>
<SectionTitle>情報</SectionTitle>
<ul>
<li>Id.: {post.id}</li>
{/* TODO: uploadedUser の取得を対応したらコメント外す */}
{/*
<li>
<>耕作者: </>
{post.uploadedUser
? (
<Link to={`/users/${ post.uploadedUser.id }`}>
{post.uploadedUser.name || '名もなきニジラー'}
</Link>)
: 'bot操作'}
</li>
*/}
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li>
<li>
<>リンク: </>
<a
className="break-all"
href={post.url}
target="_blank"
rel="noopener noreferrer nofollow">
{post.url}
</a>
</li>
<li>
{/* TODO: 表示形式きしょすぎるので何とかする */}
<>オリジナルの投稿日時: </>
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
? '不明'
: (
<>
{post.originalCreatedFrom
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
{post.originalCreatedBefore
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
</>)}
</li>
</ul>
</div>)}
</motion.div>
</SidebarComponent>)
}) satisfies FC<Props>

+ 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 () }`}


+ 56
- 32
frontend/src/components/TagSidebar.tsx View File

@@ -1,4 +1,5 @@
import axios from 'axios'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'

@@ -8,7 +9,6 @@ import SectionTitle from '@/components/common/SectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { API_BASE_URL } from '@/config'
import { CATEGORIES } from '@/consts'
import { cn } from '@/lib/utils'

import type { FC } from 'react'

@@ -58,47 +58,71 @@ export default (({ posts }: Props) => {
setTags (tagsTmp)
}, [posts])

const TagBlock = (
<>
<SectionTitle>タグ</SectionTitle>
<ul>
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<TagLink tag={tag}/>
</li>))) : [])}
</ul>
<SectionTitle>関聯</SectionTitle>
{posts.length > 0 && (
<a href="#"
onClick={ev => {
ev.preventDefault ()
void ((async () => {
try
{
const { data } = await axios.get (`${ API_BASE_URL }/posts/random`,
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
match: (anyFlg ? 'any' : 'all') } })
navigate (`/posts/${ (data as Post).id }`)
}
catch
{
;
}
}) ())
}}>
ランダム
</a>)}
</>)

return (
<SidebarComponent>
<TagSearch/>
<div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}>
<SectionTitle>タグ</SectionTitle>
<ul>
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<TagLink tag={tag}/>
</li>))) : [])}
</ul>
<SectionTitle>関聯</SectionTitle>
{posts.length > 0 && (
<a href="#"
onClick={ev => {
ev.preventDefault ()
void ((async () => {
try
{
const { data } = await axios.get (`${ API_BASE_URL }/posts/random`,
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
match: (anyFlg ? 'any' : 'all') } })
navigate (`/posts/${ (data as Post).id }`)
}
catch
{
;
}
}) ())
}}>
ランダム
</a>)}

<div className="hidden md:block mt-4">
{TagBlock}
</div>

<AnimatePresence initial={false}>
{tagsVsbl && (
<motion.div
key="sptags"
className="md:hidden mt-4"
variants={{ hidden: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
visible: { clipPath: 'inset(0 0 0% 0)',
height: 'auto'} }}
initial="hidden"
animate="visible"
exit="hidden"
transition={{ duration: .2, ease: 'easeOut' }}>
{TagBlock}
</motion.div>)}
</AnimatePresence>

<a href="#"
className="md:hidden block my-2 text-center text-sm
text-gray-500 hover:text-gray-400
dark:text-gray-300 dark:hover:text-gray-100"
onClick={ev => {
ev.preventDefault ()
setTagsVsbl (!(tagsVsbl))
setTagsVsbl (v => !(v))
}}>
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
</a>


+ 165
- 53
frontend/src/components/TopNav.tsx View File

@@ -1,6 +1,7 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { Fragment, useState, useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'

import Separator from '@/components/MenuSeparator'
@@ -19,6 +20,28 @@ type Props = { user: User | null }
export default (({ user }: Props) => {
const location = useLocation ()

const dirRef = useRef<(-1) | 1> (1)
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
const navRef = useRef<HTMLDivElement | null> (null)

const measure = () => {
const nav = navRef.current
const el = itemsRef.current[activeIdx]
if (!(nav) || !(el) || activeIdx < 0)
return

const navRect = nav.getBoundingClientRect ()
const elRect = el.getBoundingClientRect ()

setHl ({ left: elRect.left - navRect.left,
width: elRect.width,
visible: true })
}

const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
left: 0,
width: 0,
visible: false })
const [menuOpen, setMenuOpen] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1)
const [postCount, setPostCount] = useState<number | null> (null)
@@ -30,6 +53,7 @@ export default (({ user }: Props) => {
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '投稿追加', to: '/posts/new' },
{ name: '耕作履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'タグ一覧', to: '/tags', visible: false },
@@ -52,6 +76,32 @@ export default (({ user }: Props) => {
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]

const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to))

const prevActiveIdxRef = useRef<number> (activeIdx)

if (activeIdx !== prevActiveIdxRef.current)
{
dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1
prevActiveIdxRef.current = activeIdx
}

const dir = dirRef.current

useLayoutEffect (() => {
if (activeIdx < 0)
return

const raf = requestAnimationFrame (measure)
const onResize = () => requestAnimationFrame (measure)

addEventListener ('resize', onResize)
return () => {
cancelAnimationFrame (raf)
removeEventListener ('resize', onResize)
}
}, [activeIdx])

useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId)
return () => unsubscribe ()
@@ -97,16 +147,26 @@ export default (({ user }: Props) => {
ぼざクリ タグ広場
</Link>

{menu.map ((item, i) => (
<Link key={i}
to={item.to}
className={cn ('hidden md:flex h-full items-center',
(location.pathname.startsWith (item.base || item.to)
? 'bg-yellow-200 dark:bg-red-950 px-4 font-bold'
: 'px-2'))}>
{item.name}
</Link>
))}
<div ref={navRef} className="relative hidden md:flex h-full items-center">
<div aria-hidden
className={cn ('absolute top-1/2 -translate-y-1/2 h-full',
'bg-yellow-200 dark:bg-red-950',
'transition-[transform,width] duration-200 ease-out')}
style={{ width: hl.width,
transform: `translate(${ hl.left }px, -50%)`,
opacity: hl.visible ? 1 : 0 }}/>

{menu.map ((item, i) => (
<Link key={i}
to={item.to}
ref={el => {
itemsRef.current[i] = el
}}
className={cn ('relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}>
{item.name}
</Link>))}
</div>
</div>

<TopNavUser user={user}/>
@@ -123,49 +183,101 @@ export default (({ user }: Props) => {
</a>
</nav>

<div className="hidden md:flex bg-yellow-200 dark:bg-red-950
items-center w-full min-h-[40px] px-3">
{menu.find (item => location.pathname.startsWith (item.base || item.to))?.subMenu
.filter (item => item.visible ?? true)
.map ((item, i) => 'component' in item ? item.component : (
<Link key={i}
to={item.to}
className="h-full flex items-center px-3">
{item.name}
</Link>))}
<div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
items-center w-full min-h-[40px] overflow-hidden">
<AnimatePresence initial={false} custom={dir}>
<motion.div
key={activeIdx}
custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="absolute inset-0 flex items-center px-3"
initial="enter"
animate="centre"
exit="exit"
transition={{ duration: .2, ease: 'easeOut' }}>
{(menu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
: (
<Link key={`l-${ i }`}
to={item.to}
className="h-full flex items-center px-3">
{item.name}
</Link>)))}
</motion.div>
</AnimatePresence>
</div>

<div className={cn (menuOpen ? 'flex flex-col md:hidden' : 'hidden',
'bg-yellow-200 dark:bg-red-975 items-start')}>
<Separator/>
{menu.map ((item, i) => (
<Fragment key={i}>
<Link to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
onClick={ev => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
{item.name}
</Link>
{i === openItemIdx && (
item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => 'component' in subItem ? subItem.component : (
<Link key={j}
to={subItem.to}
className="w-full min-h-[36px] flex items-center pl-12
bg-yellow-50 dark:bg-red-950">
{subItem.name}
</Link>)))}
</Fragment>))}
<TopNavUser user={user} sp/>
<Separator/>
</div>
<AnimatePresence initial={false}>
{menuOpen && (
<motion.div
key="spmenu"
className={cn ('flex flex-col md:hidden',
'bg-yellow-200 dark:bg-red-975 items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto' } }}
initial="closed"
animate="open"
exit="closed"
transition={{ duration: .2, ease: 'easeOut' }}>
<Separator/>
{menu.map ((item, i) => (
<Fragment key={i}>
<Link to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
onClick={ev => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
{item.name}
</Link>

<AnimatePresence initial={false}>
{i === openItemIdx && (
<motion.div
key={`sp-sub-${ i }`}
className="w-full bg-yellow-50 dark:bg-red-950"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0,
opacity: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto',
opacity: 1 } }}
initial="closed"
animate="open"
exit="closed"
transition={{ duration: .2, ease: 'easeOut' }}>
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<Link key={`sp-l-${ i }-${ j }`}
to={subItem.to}
className="w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</Link>)))}
</motion.div>)}
</AnimatePresence>
</Fragment>))}
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
</AnimatePresence>
</>)
}) satisfies FC<Props>

+ 79
- 0
frontend/src/components/common/Pagination.tsx View File

@@ -0,0 +1,79 @@
import { Link, useLocation } from 'react-router-dom'

import type { FC } from 'react'

type Props = { page: number
totalPages: number
siblingCount?: number }


const range = (start: number, end: number): number[] =>
[...Array (end - start + 1).keys ()].map (i => start + i)


const getPages = (
page: number,
total: number,
siblingCount: number,
): (number | '…')[] => {
if (total <= 1)
return [1]

const first = 1
const last = total

const left = Math.max (page - siblingCount, first)
const right = Math.min (page + siblingCount, last)

const pages: (number | '…')[] = []

pages.push (first)

if (left > first + 1)
pages.push ('…')

const midStart = Math.max (left, first + 1)
const midEnd = Math.min (right, last - 1)
pages.push (...range (midStart, midEnd))

if (right < last - 1)
pages.push ('…')

if (last !== first)
pages.push (last)

return pages.filter ((v, i, arr) => i === 0 || v !== arr[i - 1])
}


export default (({ page, totalPages, siblingCount = 4 }) => {
const location = useLocation ()

const buildTo = (p: number) => {
const qs = new URLSearchParams (location.search)
qs.set ('page', String (p))
return `${ location.pathname }?${ qs.toString () }`
}

const pages = getPages (page, totalPages, siblingCount)

return (
<nav className="mt-4 flex justify-center" aria-label="Pagination">
<div className="flex items-center gap-2">
{(page > 1)
? <Link to={buildTo (page - 1)} aria-label="前のページ">&lt;</Link>
: <span aria-hidden>&lt;</span>}

{pages.map ((p, idx) => (
(p === '…')
? <span key={`dots-${ idx }`}>…</span>
: ((p === page)
? <span key={p} className="font-bold" aria-current="page">{p}</span>
: <Link key={p} to={buildTo (p)}>{p}</Link>)))}

{(page < totalPages)
? <Link to={buildTo (page + 1)} aria-label="次のページ">&gt;</Link>
: <span aria-hidden>&gt;</span>}
</div>
</nav>)
}) satisfies FC<Props>

+ 90
- 0
frontend/src/pages/posts/PostHistoryPage.tsx View File

@@ -0,0 +1,90 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { Link, useLocation } from 'react-router-dom'

import TagLink from '@/components/TagLink'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { API_BASE_URL, SITE_TITLE } from '@/config'

import type { FC } from 'react'

import type { PostTagChange } from '@/types'


export default (() => {
const [changes, setChanges] = useState<PostTagChange[]> ([])
const [totalPages, setTotalPages] = useState<number> (0)

const location = useLocation ()
const query = new URLSearchParams (location.search)
const id = query.get ('id')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)

useEffect (() => {
void (async () => {
const res = await axios.get (`${ API_BASE_URL }/posts/changes`,
{ params: { ...(id && { id }),
...(page && { page }),
...(limit && { limit }) } })
const data = toCamel (res.data as any, { deep: true }) as {
changes: PostTagChange[]
count: number }
setChanges (data.changes)
setTotalPages (Math.trunc ((data.count - 1) / limit))
}) ()
}, [location.search])

return (
<MainArea>
<Helmet>
<title>{`耕作履歴 | ${ SITE_TITLE }`}</title>
</Helmet>

<PageTitle>
耕作履歴
{Boolean (id) && <>: 投稿 {<Link to={`/posts/${ id }`}>#{id}</Link>}</>}
</PageTitle>

<table className="table-auto w-full border-collapse">
<thead>
<tr>
<th className="p-2 text-left">投稿</th>
<th className="p-2 text-left">変更</th>
<th className="p-2 text-left">日時</th>
</tr>
</thead>
<tbody>
{changes.map (change => (
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}>
<td>
<Link to={`/posts/${ change.post.id }`}>
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
alt={change.post.title || change.post.url}
title={change.post.title || change.post.url || undefined}
className="w-40"/>
</Link>
</td>
<td>
<TagLink tag={change.tag} withWiki={false} withCount={false}/>
{`を${ change.changeType === 'add' ? '追加' : '削除' }`}
</td>
<td>
{change.user ? (
<Link to={`/users/${ change.user.id }`}>
{change.user.name}
</Link>) : 'bot 操作'}
<br/>
{change.timestamp}
</td>
</tr>))}
</tbody>
</table>

<Pagination page={page} totalPages={totalPages}/>
</MainArea>)
}) satisfies FC

+ 17
- 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,26 @@ 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 PostTagChange = {
post: Post
tag: Tag
user?: User
changeType: 'add' | 'remove'
timestamp: string }

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