Merge branch 'main' into feature/142
このコミットが含まれているのは:
@@ -63,3 +63,5 @@ gem 'diff-lcs'
|
|||||||
gem 'dotenv-rails'
|
gem 'dotenv-rails'
|
||||||
|
|
||||||
gem 'whenever', require: false
|
gem 'whenever', require: false
|
||||||
|
|
||||||
|
gem 'discard'
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ GEM
|
|||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
diff-lcs (1.6.2)
|
diff-lcs (1.6.2)
|
||||||
|
discard (1.4.0)
|
||||||
|
activerecord (>= 4.2, < 9.0)
|
||||||
dotenv (3.1.8)
|
dotenv (3.1.8)
|
||||||
dotenv-rails (3.1.8)
|
dotenv-rails (3.1.8)
|
||||||
dotenv (= 3.1.8)
|
dotenv (= 3.1.8)
|
||||||
@@ -420,6 +422,7 @@ DEPENDENCIES
|
|||||||
bootsnap
|
bootsnap
|
||||||
brakeman
|
brakeman
|
||||||
diff-lcs
|
diff-lcs
|
||||||
|
discard
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
gollum
|
gollum
|
||||||
image_processing (~> 1.14)
|
image_processing (~> 1.14)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
require 'open-uri'
|
|
||||||
require 'nokogiri'
|
|
||||||
|
|
||||||
|
|
||||||
class PostsController < ApplicationController
|
class PostsController < ApplicationController
|
||||||
|
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
|
||||||
|
|
||||||
# GET /posts
|
# GET /posts
|
||||||
def index
|
def index
|
||||||
limit = params[:limit].presence&.to_i
|
limit = params[:limit].presence&.to_i
|
||||||
@@ -80,8 +78,9 @@ class PostsController < ApplicationController
|
|||||||
post.thumbnail.attach(thumbnail)
|
post.thumbnail.attach(thumbnail)
|
||||||
if post.save
|
if post.save
|
||||||
post.resized_thumbnail!
|
post.resized_thumbnail!
|
||||||
post.tags = Tag.normalise_tags(tag_names)
|
tags = Tag.normalise_tags(tag_names)
|
||||||
post.tags = Tag.expand_parent_tags(post.tags)
|
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
|
||||||
@@ -114,10 +113,11 @@ class PostsController < ApplicationController
|
|||||||
original_created_before = params[:original_created_before]
|
original_created_before = params[:original_created_before]
|
||||||
|
|
||||||
post = Post.find(params[:id].to_i)
|
post = Post.find(params[:id].to_i)
|
||||||
|
if post.update(title:, original_created_from:, original_created_before:)
|
||||||
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)
|
tags = Tag.expand_parent_tags(tags)
|
||||||
if post.update(title:, tags:, original_created_from:, original_created_before:)
|
sync_post_tags!(post, tags)
|
||||||
json = post.as_json
|
json = post.as_json
|
||||||
json['tags'] = build_tag_tree_for(post.tags)
|
json['tags'] = build_tag_tree_for(post.tags)
|
||||||
render json:, status: :ok
|
render json:, status: :ok
|
||||||
@@ -130,12 +130,54 @@ class PostsController < ApplicationController
|
|||||||
def destroy
|
def destroy
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def filtered_posts
|
def filtered_posts
|
||||||
tag_names = params[:tags]&.split(' ')
|
tag_names = params[:tags]&.split(' ')
|
||||||
match_type = params[:match]
|
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
|
end
|
||||||
|
|
||||||
def filter_posts_by_tags tag_names, match_type
|
def filter_posts_by_tags tag_names, match_type
|
||||||
@@ -150,6 +192,30 @@ class PostsController < ApplicationController
|
|||||||
posts.distinct
|
posts.distinct
|
||||||
end
|
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
|
def build_tag_tree_for tags
|
||||||
tags = tags.to_a
|
tags = tags.to_a
|
||||||
tag_ids = tags.map(&:id)
|
tag_ids = tags.map(&:id)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
class Post < ApplicationRecord
|
||||||
require 'mini_magick'
|
require 'mini_magick'
|
||||||
|
|
||||||
|
|
||||||
class Post < ApplicationRecord
|
|
||||||
belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
|
belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
|
||||||
belongs_to :uploaded_user, class_name: 'User', optional: true
|
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 :user_post_views, dependent: :destroy
|
||||||
has_many :post_similarities_as_post,
|
has_many :post_similarities_as_post,
|
||||||
class_name: 'PostSimilarity',
|
class_name: 'PostSimilarity',
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
class PostTag < ApplicationRecord
|
class PostTag < ApplicationRecord
|
||||||
|
include Discard::Model
|
||||||
|
|
||||||
belongs_to :post
|
belongs_to :post
|
||||||
belongs_to :tag, counter_cache: :post_count
|
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 :post_id, presence: true
|
||||||
validates :tag_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
|
end
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
class Tag < ApplicationRecord
|
class Tag < ApplicationRecord
|
||||||
has_many :post_tags, dependent: :destroy
|
has_many :post_tags, dependent: :delete_all, inverse_of: :tag
|
||||||
has_many :posts, through: :post_tags
|
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 :tag_aliases, dependent: :destroy
|
||||||
|
|
||||||
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
|
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
|
||||||
@@ -43,13 +45,13 @@ class Tag < ApplicationRecord
|
|||||||
'meta:' => 'meta' }.freeze
|
'meta:' => 'meta' }.freeze
|
||||||
|
|
||||||
def self.tagme
|
def self.tagme
|
||||||
@tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag|
|
@tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag|
|
||||||
tag.category = 'meta'
|
tag.category = 'meta'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.bot
|
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'
|
tag.category = 'meta'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Rails.application.routes.draw do
|
|||||||
get 'tags/autocomplete', to: 'tags#autocomplete'
|
get 'tags/autocomplete', to: 'tags#autocomplete'
|
||||||
get 'tags/name/:name', to: 'tags#show_by_name'
|
get 'tags/name/:name', to: 'tags#show_by_name'
|
||||||
get 'posts/random', to: 'posts#random'
|
get 'posts/random', to: 'posts#random'
|
||||||
|
get 'posts/changes', to: 'posts#changes'
|
||||||
post 'posts/:id/viewed', to: 'posts#viewed'
|
post 'posts/:id/viewed', to: 'posts#viewed'
|
||||||
delete 'posts/:id/viewed', to: 'posts#unviewed'
|
delete 'posts/:id/viewed', to: 'posts#unviewed'
|
||||||
get 'preview/title', to: 'preview#title'
|
get 'preview/title', to: 'preview#title'
|
||||||
|
|||||||
@@ -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,12 +5,32 @@ namespace :nico do
|
|||||||
require 'open-uri'
|
require 'open-uri'
|
||||||
require 'nokogiri'
|
require 'nokogiri'
|
||||||
|
|
||||||
fetch_thumbnail = -> url {
|
fetch_thumbnail = -> url do
|
||||||
html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read
|
html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read
|
||||||
doc = Nokogiri::HTML(html)
|
doc = Nokogiri::HTML(html)
|
||||||
|
|
||||||
doc.at('meta[name="thumbnail"]')&.[]('content').presence
|
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_user = ENV['MYSQL_USER']
|
||||||
mysql_pass = ENV['MYSQL_PASS']
|
mysql_pass = ENV['MYSQL_PASS']
|
||||||
@@ -19,7 +39,8 @@ namespace :nico do
|
|||||||
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
|
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
|
||||||
'python3', "#{ nizika_nico_path }/get_videos.py")
|
'python3', "#{ nizika_nico_path }/get_videos.py")
|
||||||
|
|
||||||
if status.success?
|
abort unless status.success?
|
||||||
|
|
||||||
data = JSON.parse(stdout)
|
data = JSON.parse(stdout)
|
||||||
data.each do |datum|
|
data.each do |datum|
|
||||||
post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post|
|
post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post|
|
||||||
@@ -38,24 +59,37 @@ namespace :nico do
|
|||||||
end
|
end
|
||||||
post.save!
|
post.save!
|
||||||
post.resized_thumbnail!
|
post.resized_thumbnail!
|
||||||
|
sync_post_tags!(post, [Tag.tagme.id])
|
||||||
end
|
end
|
||||||
|
|
||||||
current_tags = post.tags.where(category: 'nico').pluck(:name).sort
|
kept_tags = post.tags.reload
|
||||||
new_tags = datum['tags'].map { |tag| "nico:#{ tag }" }.sort
|
kept_non_nico_ids = kept_tags.where.not(category: 'nico').pluck(:id).to_set
|
||||||
if current_tags != new_tags
|
|
||||||
post.tags.destroy(post.tags.where(name: current_tags))
|
desired_nico_ids = []
|
||||||
tags_to_add = []
|
desired_non_nico_ids = []
|
||||||
new_tags.each do |name|
|
datum['tags'].each do |raw|
|
||||||
|
name = "nico:#{ raw }"
|
||||||
tag = Tag.find_or_initialize_by(name:) do |t|
|
tag = Tag.find_or_initialize_by(name:) do |t|
|
||||||
t.category = 'nico'
|
t.category = 'nico'
|
||||||
end
|
end
|
||||||
tags_to_add.concat([tag] + tag.linked_tags)
|
tag.save! if tag.new_record?
|
||||||
end
|
desired_nico_ids << tag.id
|
||||||
tags_to_add << Tag.tagme if post.tags.size < 10
|
unless tag.in?(kept_tags)
|
||||||
tags_to_add << Tag.bot
|
desired_non_nico_ids.concat(tag.linked_tags.pluck(:id))
|
||||||
post.tags = (post.tags + tags_to_add).uniq
|
desired_nico_ids.concat(tag.linked_tags.pluck(:id))
|
||||||
end
|
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|||||||
生成ファイル
+43
@@ -17,6 +17,7 @@
|
|||||||
"camelcase-keys": "^9.1.3",
|
"camelcase-keys": "^9.1.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.23.26",
|
||||||
"humps": "^2.0.1",
|
"humps": "^2.0.1",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
@@ -3573,6 +3574,33 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -5216,6 +5244,21 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"camelcase-keys": "^9.1.3",
|
"camelcase-keys": "^9.1.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.23.26",
|
||||||
"humps": "^2.0.1",
|
"humps": "^2.0.1",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`,
|
|||||||
{ params: { ...(tagName && { tags: tagName,
|
{ params: { ...(tagName && { tags: tagName,
|
||||||
match: 'all',
|
match: 'all',
|
||||||
limit: '20' }) } })).data.posts
|
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 fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data
|
||||||
const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name)
|
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="flex gap-4"><a href="#" class="font-bold">広場</a></div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="flex flex-wrap gap-6 p-4">
|
<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"
|
<a class="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
|
||||||
href="/posts/${ post.id }">
|
href="/posts/${ post.id }">
|
||||||
<img alt="${ post.title }"
|
<img alt="${ post.title }"
|
||||||
@@ -42,7 +41,7 @@ const createPostListOutlet = async tagName => `
|
|||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
class="object-none w-full h-full"
|
class="object-none w-full h-full"
|
||||||
src="${ post.url }" />
|
src="${ post.thumbnail }" />
|
||||||
</a>`).join ('') }
|
</a>`).join ('') }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { API_BASE_URL } from '@/config'
|
|||||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||||
|
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||||
import PostListPage from '@/pages/posts/PostListPage'
|
import PostListPage from '@/pages/posts/PostListPage'
|
||||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||||
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||||
@@ -79,6 +80,7 @@ export default (() => {
|
|||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
<Route path="/posts/:id" element={<PostDetailPage 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="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
@@ -22,14 +23,19 @@ const renderTagTree = (
|
|||||||
const key = `${ path }-${ tag.id }`
|
const key = `${ path }-${ tag.id }`
|
||||||
|
|
||||||
const self = (
|
const self = (
|
||||||
<li key={key} className="mb-1">
|
<motion.li
|
||||||
|
key={key}
|
||||||
|
layout
|
||||||
|
transition={{ duration: .2, ease: 'easeOut' }}
|
||||||
|
className="mb-1">
|
||||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||||
</li>)
|
</motion.li>)
|
||||||
|
|
||||||
return [self,
|
return [self,
|
||||||
...(tag.children
|
...((tag.children
|
||||||
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
||||||
.flatMap (child => renderTagTree (child, nestLevel + 1, key)) ?? [])]
|
.flatMap (child => renderTagTree (child, nestLevel + 1, key)))
|
||||||
|
?? [])]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,13 +76,17 @@ export default (({ post }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<SidebarComponent>
|
<SidebarComponent>
|
||||||
<TagSearch/>
|
<TagSearch/>
|
||||||
|
<motion.div key={post?.id ?? 0} layout>
|
||||||
{CATEGORIES.map ((cat: Category) => cat in tags && (
|
{CATEGORIES.map ((cat: Category) => cat in tags && (
|
||||||
<div className="my-3" key={cat}>
|
<motion.div layout className="my-3" key={cat}>
|
||||||
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
|
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
|
||||||
<ul>
|
|
||||||
|
<motion.ul layout>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
{tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
|
{tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
|
||||||
</ul>
|
</AnimatePresence>
|
||||||
</div>))}
|
</motion.ul>
|
||||||
|
</motion.div>))}
|
||||||
{post && (
|
{post && (
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle>情報</SectionTitle>
|
<SectionTitle>情報</SectionTitle>
|
||||||
@@ -120,5 +130,6 @@ export default (({ post }: Props) => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
</motion.div>
|
||||||
</SidebarComponent>)
|
</SidebarComponent>)
|
||||||
}) satisfies FC<Props>
|
}) satisfies FC<Props>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -8,7 +9,6 @@ import SectionTitle from '@/components/common/SectionTitle'
|
|||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||||
import { API_BASE_URL } from '@/config'
|
import { API_BASE_URL } from '@/config'
|
||||||
import { CATEGORIES } from '@/consts'
|
import { CATEGORIES } from '@/consts'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -58,10 +58,8 @@ export default (({ posts }: Props) => {
|
|||||||
setTags (tagsTmp)
|
setTags (tagsTmp)
|
||||||
}, [posts])
|
}, [posts])
|
||||||
|
|
||||||
return (
|
const TagBlock = (
|
||||||
<SidebarComponent>
|
<>
|
||||||
<TagSearch/>
|
|
||||||
<div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}>
|
|
||||||
<SectionTitle>タグ</SectionTitle>
|
<SectionTitle>タグ</SectionTitle>
|
||||||
<ul>
|
<ul>
|
||||||
{CATEGORIES.flatMap (cat => cat in tags ? (
|
{CATEGORIES.flatMap (cat => cat in tags ? (
|
||||||
@@ -91,14 +89,40 @@ export default (({ posts }: Props) => {
|
|||||||
}}>
|
}}>
|
||||||
ランダム
|
ランダム
|
||||||
</a>)}
|
</a>)}
|
||||||
|
</>)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarComponent>
|
||||||
|
<TagSearch/>
|
||||||
|
|
||||||
|
<div className="hidden md:block mt-4">
|
||||||
|
{TagBlock}
|
||||||
</div>
|
</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="#"
|
<a href="#"
|
||||||
className="md:hidden block my-2 text-center text-sm
|
className="md:hidden block my-2 text-center text-sm
|
||||||
text-gray-500 hover:text-gray-400
|
text-gray-500 hover:text-gray-400
|
||||||
dark:text-gray-300 dark:hover:text-gray-100"
|
dark:text-gray-300 dark:hover:text-gray-100"
|
||||||
onClick={ev => {
|
onClick={ev => {
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
setTagsVsbl (!(tagsVsbl))
|
setTagsVsbl (v => !(v))
|
||||||
}}>
|
}}>
|
||||||
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
|
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import toCamel from 'camelcase-keys'
|
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 { Link, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import Separator from '@/components/MenuSeparator'
|
import Separator from '@/components/MenuSeparator'
|
||||||
@@ -19,6 +20,28 @@ type Props = { user: User | null }
|
|||||||
export default (({ user }: Props) => {
|
export default (({ user }: Props) => {
|
||||||
const location = useLocation ()
|
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 [menuOpen, setMenuOpen] = useState (false)
|
||||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||||
const [postCount, setPostCount] = useState<number | null> (null)
|
const [postCount, setPostCount] = useState<number | null> (null)
|
||||||
@@ -30,6 +53,7 @@ export default (({ user }: Props) => {
|
|||||||
{ name: '広場', to: '/posts', subMenu: [
|
{ name: '広場', to: '/posts', subMenu: [
|
||||||
{ name: '一覧', to: '/posts' },
|
{ name: '一覧', to: '/posts' },
|
||||||
{ name: '投稿追加', to: '/posts/new' },
|
{ name: '投稿追加', to: '/posts/new' },
|
||||||
|
{ name: '耕作履歴', to: '/posts/changes' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||||
{ name: 'タグ', to: '/tags', subMenu: [
|
{ name: 'タグ', to: '/tags', subMenu: [
|
||||||
{ name: 'タグ一覧', to: '/tags', visible: false },
|
{ name: 'タグ一覧', to: '/tags', visible: false },
|
||||||
@@ -52,6 +76,32 @@ export default (({ user }: Props) => {
|
|||||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
|
{ 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 (() => {
|
useEffect (() => {
|
||||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||||
return () => unsubscribe ()
|
return () => unsubscribe ()
|
||||||
@@ -97,16 +147,26 @@ export default (({ user }: Props) => {
|
|||||||
ぼざクリ タグ広場
|
ぼざクリ タグ広場
|
||||||
</Link>
|
</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) => (
|
{menu.map ((item, i) => (
|
||||||
<Link key={i}
|
<Link key={i}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={cn ('hidden md:flex h-full items-center',
|
ref={el => {
|
||||||
(location.pathname.startsWith (item.base || item.to)
|
itemsRef.current[i] = el
|
||||||
? 'bg-yellow-200 dark:bg-red-950 px-4 font-bold'
|
}}
|
||||||
: 'px-2'))}>
|
className={cn ('relative z-10 flex h-full items-center px-5',
|
||||||
|
(i === openItemIdx) && 'font-bold')}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TopNavUser user={user}/>
|
<TopNavUser user={user}/>
|
||||||
@@ -123,20 +183,49 @@ export default (({ user }: Props) => {
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="hidden md:flex bg-yellow-200 dark:bg-red-950
|
<div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
|
||||||
items-center w-full min-h-[40px] px-3">
|
items-center w-full min-h-[40px] overflow-hidden">
|
||||||
{menu.find (item => location.pathname.startsWith (item.base || item.to))?.subMenu
|
<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)
|
.filter (item => item.visible ?? true)
|
||||||
.map ((item, i) => 'component' in item ? item.component : (
|
.map ((item, i) => (
|
||||||
<Link key={i}
|
'component' in item
|
||||||
|
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
|
||||||
|
: (
|
||||||
|
<Link key={`l-${ i }`}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className="h-full flex items-center px-3">
|
className="h-full flex items-center px-3">
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>))}
|
</Link>)))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn (menuOpen ? 'flex flex-col md:hidden' : 'hidden',
|
<AnimatePresence initial={false}>
|
||||||
'bg-yellow-200 dark:bg-red-975 items-start')}>
|
{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/>
|
<Separator/>
|
||||||
{menu.map ((item, i) => (
|
{menu.map ((item, i) => (
|
||||||
<Fragment key={i}>
|
<Fragment key={i}>
|
||||||
@@ -153,19 +242,42 @@ export default (({ user }: Props) => {
|
|||||||
}}>
|
}}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
{i === openItemIdx && (
|
{i === openItemIdx && (
|
||||||
item.subMenu
|
<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)
|
.filter (subItem => subItem.visible ?? true)
|
||||||
.map ((subItem, j) => 'component' in subItem ? subItem.component : (
|
.map ((subItem, j) => (
|
||||||
<Link key={j}
|
'component' in subItem
|
||||||
|
? (
|
||||||
|
<Fragment key={`sp-c-${ i }-${ j }`}>
|
||||||
|
{subItem.component}
|
||||||
|
</Fragment>)
|
||||||
|
: (
|
||||||
|
<Link key={`sp-l-${ i }-${ j }`}
|
||||||
to={subItem.to}
|
to={subItem.to}
|
||||||
className="w-full min-h-[36px] flex items-center pl-12
|
className="w-full min-h-[36px] flex items-center pl-12">
|
||||||
bg-yellow-50 dark:bg-red-950">
|
|
||||||
{subItem.name}
|
{subItem.name}
|
||||||
</Link>)))}
|
</Link>)))}
|
||||||
|
</motion.div>)}
|
||||||
|
</AnimatePresence>
|
||||||
</Fragment>))}
|
</Fragment>))}
|
||||||
<TopNavUser user={user} sp/>
|
<TopNavUser user={user} sp/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
</div>
|
</motion.div>)}
|
||||||
|
</AnimatePresence>
|
||||||
</>)
|
</>)
|
||||||
}) satisfies FC<Props>
|
}) satisfies FC<Props>
|
||||||
|
|||||||
@@ -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="前のページ"><</Link>
|
||||||
|
: <span aria-hidden><</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="次のページ">></Link>
|
||||||
|
: <span aria-hidden>></span>}
|
||||||
|
</div>
|
||||||
|
</nav>)
|
||||||
|
}) satisfies FC<Props>
|
||||||
@@ -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
|
||||||
@@ -36,9 +36,16 @@ export default () => {
|
|||||||
if (/^\d+$/.test (title))
|
if (/^\d+$/.test (title))
|
||||||
{
|
{
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
try
|
||||||
|
{
|
||||||
const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`)
|
const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`)
|
||||||
const data = res.data as WikiPage
|
const data = res.data as WikiPage
|
||||||
navigate (`/wiki/${ data.title }`, { replace: true })
|
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
;
|
||||||
|
}
|
||||||
}) ()
|
}) ()
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -51,6 +58,8 @@ export default () => {
|
|||||||
`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`,
|
`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`,
|
||||||
{ params: version ? { version } : { } })
|
{ params: version ? { version } : { } })
|
||||||
const data = toCamel (res.data as any, { deep: true }) as WikiPage
|
const data = toCamel (res.data as any, { deep: true }) as WikiPage
|
||||||
|
if (data.title !== title)
|
||||||
|
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
|
||||||
setWikiPage (data)
|
setWikiPage (data)
|
||||||
WikiIdBus.set (data.id)
|
WikiIdBus.set (data.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export type Post = {
|
|||||||
originalCreatedFrom: string | null
|
originalCreatedFrom: string | null
|
||||||
originalCreatedBefore: string | null }
|
originalCreatedBefore: string | null }
|
||||||
|
|
||||||
|
export type PostTagChange = {
|
||||||
|
post: Post
|
||||||
|
tag: Tag
|
||||||
|
user?: User
|
||||||
|
changeType: 'add' | 'remove'
|
||||||
|
timestamp: string }
|
||||||
|
|
||||||
export type SubMenuItem =
|
export type SubMenuItem =
|
||||||
| { component: ReactNode
|
| { component: ReactNode
|
||||||
visible: boolean }
|
visible: boolean }
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする