Merge remote-tracking branch 'origin/main' into '#106'
このコミットが含まれているのは:
@@ -63,3 +63,5 @@ gem 'diff-lcs'
|
||||
gem 'dotenv-rails'
|
||||
|
||||
gem 'whenever', require: false
|
||||
|
||||
gem 'discard'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,34 +1,50 @@
|
||||
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
|
||||
page = (params[:page].presence || 1).to_i
|
||||
limit = (params[:limit].presence || 20).to_i
|
||||
cursor = params[:cursor].presence
|
||||
|
||||
q = filtered_posts.order(created_at: :desc)
|
||||
q = q.where('posts.created_at < ?', Time.iso8601(cursor)) if cursor
|
||||
page = 1 if page < 1
|
||||
limit = 1 if limit < 1
|
||||
|
||||
posts = limit ? q.limit(limit + 1) : q
|
||||
offset = (page - 1) * limit
|
||||
|
||||
sort_sql =
|
||||
'COALESCE(posts.original_created_before - INTERVAL 1 SECOND,' +
|
||||
'posts.original_created_from,' +
|
||||
'posts.created_at)'
|
||||
q =
|
||||
filtered_posts
|
||||
.preload(:tags)
|
||||
.with_attached_thumbnail
|
||||
.select("posts.*, #{ sort_sql } AS sort_ts")
|
||||
.order(Arel.sql("#{ sort_sql } DESC"))
|
||||
posts = (
|
||||
if cursor
|
||||
q.where("#{ sort_sql } < ?", Time.iso8601(cursor)).limit(limit + 1)
|
||||
else
|
||||
q.limit(limit).offset(offset)
|
||||
end).to_a
|
||||
|
||||
next_cursor = nil
|
||||
if limit && posts.size > limit
|
||||
next_cursor = posts.last.created_at.iso8601(6)
|
||||
if cursor && posts.length > limit
|
||||
next_cursor = posts.last.read_attribute('sort_ts').iso8601(6)
|
||||
posts = posts.first(limit)
|
||||
end
|
||||
|
||||
render json: { posts: posts.map { |post|
|
||||
post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap { |json|
|
||||
post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap do |json|
|
||||
json['thumbnail'] =
|
||||
if post.thumbnail.attached?
|
||||
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
||||
else
|
||||
nil
|
||||
end
|
||||
}
|
||||
}, next_cursor: }
|
||||
end
|
||||
}, count: filtered_posts.count(:id), next_cursor: }
|
||||
end
|
||||
|
||||
def random
|
||||
@@ -39,7 +55,7 @@ class PostsController < ApplicationController
|
||||
|
||||
render json: (post
|
||||
.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } })
|
||||
.merge(viewed: viewed))
|
||||
.merge(viewed:))
|
||||
end
|
||||
|
||||
# GET /posts/1
|
||||
@@ -49,9 +65,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
|
||||
@@ -60,18 +79,23 @@ class PostsController < ApplicationController
|
||||
return head :forbidden unless current_user.member?
|
||||
|
||||
# TODO: URL が正規のものがチェック,不正ならエラー
|
||||
# TODO: title、URL は必須にする.
|
||||
# TODO: URL は必須にする(タイトルは省略可).
|
||||
# TODO: サイトに応じて thumbnail_base 設定
|
||||
title = params[:title]
|
||||
url = params[:url]
|
||||
thumbnail = params[:thumbnail]
|
||||
tag_names = params[:tags].to_s.split(' ')
|
||||
original_created_from = params[:original_created_from]
|
||||
original_created_before = params[:original_created_before]
|
||||
|
||||
post = Post.new(title:, url:, thumbnail_base: '', uploaded_user: current_user)
|
||||
post = Post.new(title:, url:, thumbnail_base: '', uploaded_user: current_user,
|
||||
original_created_from:, original_created_before:)
|
||||
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
|
||||
@@ -100,12 +124,18 @@ class PostsController < ApplicationController
|
||||
|
||||
title = params[:title]
|
||||
tag_names = params[:tags].to_s.split(' ')
|
||||
original_created_from = params[:original_created_from]
|
||||
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:)
|
||||
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
|
||||
@@ -115,12 +145,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
|
||||
@@ -134,4 +206,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,22 @@ class WikiPagesController < ApplicationController
|
||||
render_wiki_page_or_404 WikiPage.find_by(title: params[:title])
|
||||
end
|
||||
|
||||
def exists
|
||||
if WikiPage.exists?(params[:id])
|
||||
head :no_content
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def exists_by_title
|
||||
if WikiPage.exists?(title: params[:title])
|
||||
head :no_content
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def diff
|
||||
id = params[:id]
|
||||
from = params[:from]
|
||||
|
||||
@@ -6,6 +6,7 @@ class NicoTagRelation < ApplicationRecord
|
||||
validates :tag_id, presence: true
|
||||
|
||||
validate :nico_tag_must_be_nico
|
||||
validate :tag_mustnt_be_nico
|
||||
|
||||
private
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -15,6 +17,8 @@ class Post < ApplicationRecord
|
||||
foreign_key: :target_post_id
|
||||
has_one_attached :thumbnail
|
||||
|
||||
validate :validate_original_created_range
|
||||
|
||||
def as_json options = { }
|
||||
super(options).merge({ thumbnail: thumbnail.attached? ?
|
||||
Rails.application.routes.url_helpers.rails_blob_url(
|
||||
@@ -49,4 +53,20 @@ class Post < ApplicationRecord
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_original_created_range
|
||||
f = original_created_from
|
||||
b = original_created_before
|
||||
return if f.blank? || b.blank?
|
||||
|
||||
f = Time.zone.parse(f) if String === f
|
||||
b = Time.zone.parse(b) if String === b
|
||||
return if !(f) || !(b)
|
||||
|
||||
if f >= b
|
||||
errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
+39
-6
@@ -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,19 +45,19 @@ 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
|
||||
|
||||
def self.no_deerjikist
|
||||
@no_deerjikist ||= Tag.find_or_initialize_by(name: 'ニジラー情報なし') do |tag|
|
||||
@no_deerjikist ||= Tag.find_or_initialize_by(name: 'ニジラー情報不詳') do |tag|
|
||||
tag.category = 'meta'
|
||||
end
|
||||
end
|
||||
@@ -63,11 +73,34 @@ 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 << Tag.no_deerjikist if tags.all? { |t| t.category != 'deerjika' }
|
||||
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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class TagImplication < ApplicationRecord
|
||||
belongs_to :tag, class_name: 'Tag'
|
||||
belongs_to :parent_tag, class_name: 'Tag'
|
||||
|
||||
validates :tag_id, presence: true, uniqueness: { scope: :parent_tag_id }
|
||||
validates :parent_tag_id, presence: true
|
||||
|
||||
validate :parent_tag_mustnt_be_itself
|
||||
|
||||
private
|
||||
|
||||
def parent_tag_mustnt_be_itself
|
||||
if parent_tag == tag
|
||||
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,6 @@ class User < ApplicationRecord
|
||||
|
||||
has_many :posts
|
||||
has_many :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
|
||||
|
||||
+50
-35
@@ -1,45 +1,60 @@
|
||||
Rails.application.routes.draw do
|
||||
get 'tags/nico', to: 'nico_tags#index'
|
||||
put 'tags/nico/:id', to: 'nico_tags#update'
|
||||
get 'tags/autocomplete', to: 'tags#autocomplete'
|
||||
get 'tags/name/:name', to: 'tags#show_by_name'
|
||||
get 'posts/random', to: 'posts#random'
|
||||
post 'posts/:id/viewed', to: 'posts#viewed'
|
||||
delete 'posts/:id/viewed', to: 'posts#unviewed'
|
||||
get 'preview/title', to: 'preview#title'
|
||||
get 'preview/thumbnail', to: 'preview#thumbnail'
|
||||
get 'wiki/title/:title', to: 'wiki_pages#show_by_title'
|
||||
get 'wiki/search', to: 'wiki_pages#search'
|
||||
get 'wiki/changes', to: 'wiki_pages#changes'
|
||||
get 'wiki/:id/diff', to: 'wiki_pages#diff'
|
||||
get 'wiki/:id', to: 'wiki_pages#show'
|
||||
get 'wiki', to: 'wiki_pages#index'
|
||||
post 'wiki', to: 'wiki_pages#create'
|
||||
put 'wiki/:id', to: 'wiki_pages#update'
|
||||
post 'users/code/renew', to: 'users#renew'
|
||||
resources :nico_tags, path: 'tags/nico', only: [:index, :update]
|
||||
|
||||
resources :tags do
|
||||
collection do
|
||||
get :autocomplete
|
||||
get 'name/:name', action: :show_by_name
|
||||
end
|
||||
end
|
||||
|
||||
scope :preview, controller: :preview do
|
||||
get :title
|
||||
get :thumbnail
|
||||
end
|
||||
|
||||
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
|
||||
collection do
|
||||
get :search
|
||||
get :changes
|
||||
|
||||
scope :title do
|
||||
get ':title/exists', action: :exists_by_title
|
||||
get ':title', action: :show_by_title
|
||||
end
|
||||
end
|
||||
|
||||
member do
|
||||
get :exists
|
||||
get :diff
|
||||
end
|
||||
end
|
||||
|
||||
resources :posts do
|
||||
collection do
|
||||
get :random
|
||||
get :changes
|
||||
end
|
||||
|
||||
member do
|
||||
post :viewed
|
||||
delete :viewed, action: :unviewed
|
||||
end
|
||||
end
|
||||
|
||||
resources :users, only: [:create, :update] do
|
||||
collection do
|
||||
post :verify
|
||||
get :me
|
||||
post 'code/renew', action: :renew
|
||||
end
|
||||
end
|
||||
|
||||
resources :posts
|
||||
resources :ip_addresses
|
||||
resources :nico_tag_relations
|
||||
resources :post_tags
|
||||
resources :settings
|
||||
resources :tag_aliases
|
||||
resources :tags
|
||||
resources :user_ips
|
||||
resources :user_post_views
|
||||
resources :users, only: [:create, :update] do
|
||||
collection do
|
||||
post :verify
|
||||
get :me
|
||||
end
|
||||
end
|
||||
|
||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||
|
||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
||||
# get "up" => "rails/health#show", as: :rails_health_check
|
||||
|
||||
# Defines the root path route ("/")
|
||||
# root "posts#index"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddOriginalCreatedAtToPosts < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :posts, :original_created_from, :datetime, after: :created_at
|
||||
add_column :posts, :original_created_before, :datetime, after: :original_created_from
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class CreateTagImplications < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :tag_implications do |t|
|
||||
t.references :tag, null: false, foreign_key: { to_table: :tags }
|
||||
t.references :parent_tag, null: false, foreign_key: { to_table: :tags }
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
class RenameIpAdressColumnToIpAddresses < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
rename_column :ip_addresses, :ip_adress, :ip_address
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
class AddUniqueIndexToTagImplications < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
execute <<~SQL
|
||||
DELETE
|
||||
ti1
|
||||
FROM
|
||||
tag_implications ti1
|
||||
INNER JOIN
|
||||
tag_implications ti2
|
||||
ON
|
||||
ti1.tag_id = ti2.tag_id
|
||||
AND ti1.parent_tag_id = ti2.parent_tag_id
|
||||
AND ti1.id > ti2.id
|
||||
;
|
||||
SQL
|
||||
|
||||
add_index :tag_implications, [:tag_id, :parent_tag_id],
|
||||
unique: true,
|
||||
name: 'index_tag_implications_on_tag_id_and_parent_tag_id'
|
||||
end
|
||||
|
||||
def down
|
||||
# NOTE: 重複削除は復元されなぃ.
|
||||
remove_index :tag_implications,
|
||||
name: 'index_tag_implications_on_tag_id_and_parent_tag_id'
|
||||
end
|
||||
end
|
||||
生成ファイル
+23
-2
@@ -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_07_29_210600) 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_07_29_210600) 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_07_29_210600) 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
|
||||
|
||||
@@ -83,6 +90,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do
|
||||
t.bigint "parent_id"
|
||||
t.bigint "uploaded_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "original_created_from"
|
||||
t.datetime "original_created_before"
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["parent_id"], name: "index_posts_on_parent_id"
|
||||
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
||||
@@ -105,6 +114,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) 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
|
||||
@@ -174,6 +193,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) 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"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace :nico do
|
||||
desc 'ニコタグ連携'
|
||||
task link: :environment do
|
||||
Post.find_each do |post|
|
||||
tags = post.tags.where(category: 'nico')
|
||||
tags.each do |tag|
|
||||
post.tags.concat(tag.linked_tags) if tag.linked_tags.present?
|
||||
end
|
||||
post.tags = post.tags.to_a.uniq
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,16 +1,38 @@
|
||||
namespace :nico do
|
||||
desc 'ニコニコ DB 同期'
|
||||
task sync: :environment do
|
||||
require 'json'
|
||||
require 'nokogiri'
|
||||
require 'open3'
|
||||
require 'open-uri'
|
||||
require 'nokogiri'
|
||||
require 'set'
|
||||
|
||||
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,44 +41,60 @@ 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!
|
||||
end
|
||||
abort unless status.success?
|
||||
|
||||
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
|
||||
tags_to_add << Tag.no_deerjikist if post.tags.all? { |t| t.category != 'deerjikist' }
|
||||
post.tags = (post.tags + tags_to_add).uniq
|
||||
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
|
||||
|
||||
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
|
||||
unless Tag.where(id: desired_all_ids).where(category: 'deerjikist').exists?
|
||||
desired_all_ids << Tag.no_deerjikist.id
|
||||
end
|
||||
desired_all_ids.uniq!
|
||||
|
||||
sync_post_tags!(post, desired_all_ids)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
生成ファイル
+53
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
@@ -16,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",
|
||||
@@ -947,6 +949,15 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/noto-sans-jp": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-jp/-/noto-sans-jp-5.2.9.tgz",
|
||||
"integrity": "sha512-osPL5f7dvGDjuMuFwDTGPLG37030D8X5zk+3BWea6txAVDFeE/ZIrKW0DY0uSDfRn9+NiKbiFn/2QvZveKXTog==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -3563,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",
|
||||
@@ -5206,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",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
@@ -18,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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/>}/>
|
||||
|
||||
@@ -1,37 +1,65 @@
|
||||
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'
|
||||
import Label from '@/components/common/Label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 }
|
||||
|
||||
|
||||
export default (({ post, onSave }: Props) => {
|
||||
const [originalCreatedBefore, setOriginalCreatedBefore] =
|
||||
useState<string | null> (post.originalCreatedBefore)
|
||||
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 (`${ API_BASE_URL }/posts/${ post.id }`, { title, tags },
|
||||
const res = await axios.put (
|
||||
`${ API_BASE_URL }/posts/${ post.id }`,
|
||||
{ title, tags,
|
||||
original_created_from: originalCreatedFrom,
|
||||
original_created_before: originalCreatedBefore },
|
||||
{ headers: { 'Content-Type': 'multipart/form-data',
|
||||
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } } )
|
||||
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
|
||||
const data = toCamel (res.data as any, { deep: true }) as Post
|
||||
onSave ({ ...post,
|
||||
title: data.title,
|
||||
tags: data.tags } as Post)
|
||||
title: data.title,
|
||||
tags: data.tags,
|
||||
originalCreatedFrom: data.originalCreatedFrom,
|
||||
originalCreatedBefore: data.originalCreatedBefore } as Post)
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
setTags(tagsToStr (post.tags))
|
||||
}, [post])
|
||||
|
||||
return (
|
||||
<div className="max-w-xl pt-2 space-y-4">
|
||||
{/* タイトル */}
|
||||
@@ -40,12 +68,19 @@ export default (({ post, onSave }: Props) => {
|
||||
<input type="text"
|
||||
className="w-full border rounded p-2"
|
||||
value={title}
|
||||
onChange={e => setTitle (e.target.value)}/>
|
||||
onChange={ev => setTitle (ev.target.value)}/>
|
||||
</div>
|
||||
|
||||
{/* タグ */}
|
||||
<PostFormTagsArea tags={tags} setTags={setTags}/>
|
||||
|
||||
{/* オリジナルの作成日時 */}
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom={originalCreatedFrom}
|
||||
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
||||
originalCreatedBefore={originalCreatedBefore}
|
||||
setOriginalCreatedBefore={setOriginalCreatedBefore}/>
|
||||
|
||||
{/* 送信 */}
|
||||
<Button onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
|
||||
|
||||
@@ -12,15 +12,14 @@ export default (({ posts, onClick }: Props) => (
|
||||
<div className="flex flex-wrap gap-6 p-4">
|
||||
{posts.map ((post, i) => (
|
||||
<Link to={`/posts/${ post.id }`}
|
||||
key={i}
|
||||
key={post.id}
|
||||
className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
|
||||
onClick={onClick}>
|
||||
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
||||
alt={post.title || post.url}
|
||||
title={post.title || post.url || undefined}
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
loading={i < 12 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
className="object-none w-full h-full" />
|
||||
className="object-cover w-full h-full"/>
|
||||
</Link>))}
|
||||
</div>)) satisfies FC<Props>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import Label from '@/components/common/Label'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
originalCreatedFrom: string | null
|
||||
setOriginalCreatedFrom: (x: string | null) => void
|
||||
originalCreatedBefore: string | null
|
||||
setOriginalCreatedBefore: (x: string | null) => void }
|
||||
|
||||
|
||||
export default (({ originalCreatedFrom,
|
||||
setOriginalCreatedFrom,
|
||||
originalCreatedBefore,
|
||||
setOriginalCreatedBefore }: Props) => (
|
||||
<div>
|
||||
<Label>オリジナルの作成日時</Label>
|
||||
<div className="my-1">
|
||||
<DateTimeField
|
||||
className="mr-2"
|
||||
value={originalCreatedFrom ?? undefined}
|
||||
onChange={setOriginalCreatedFrom}
|
||||
onBlur={ev => {
|
||||
const v = ev.target.value
|
||||
if (!(v))
|
||||
return
|
||||
const d = new Date (v)
|
||||
if (d.getSeconds () === 0)
|
||||
{
|
||||
if (d.getMinutes () === 0 && d.getHours () === 0)
|
||||
d.setDate (d.getDate () + 1)
|
||||
else
|
||||
d.setMinutes (d.getMinutes () + 1)
|
||||
}
|
||||
else
|
||||
d.setSeconds (d.getSeconds () + 1)
|
||||
setOriginalCreatedBefore (d.toISOString ())
|
||||
}}/>
|
||||
以降
|
||||
</div>
|
||||
<div className="my-1">
|
||||
<DateTimeField
|
||||
className="mr-2"
|
||||
value={originalCreatedBefore ?? undefined}
|
||||
onChange={setOriginalCreatedBefore}/>
|
||||
より前
|
||||
</div>
|
||||
</div>)) satisfies FC<Props>
|
||||
@@ -1,17 +1,45 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import TagLink from '@/components/TagLink'
|
||||
import TagSearch from '@/components/TagSearch'
|
||||
import SectionTitle from '@/components/common/SectionTitle'
|
||||
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 }
|
||||
|
||||
|
||||
@@ -49,15 +77,63 @@ 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, i) => (
|
||||
<li key={i} className="mb-1">
|
||||
<TagLink tag={tag}/>
|
||||
</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>
|
||||
<li>
|
||||
<Link to={`/posts/changes?id=${ post.id }`}>履歴</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>)}
|
||||
</motion.div>
|
||||
</SidebarComponent>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import axios from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { API_BASE_URL } from '@/config'
|
||||
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -8,6 +11,7 @@ import type { ComponentProps, FC, HTMLAttributes } from 'react'
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
type CommonProps = { tag: Tag
|
||||
nestLevel?: number
|
||||
withWiki?: boolean
|
||||
withCount?: boolean }
|
||||
|
||||
@@ -21,10 +25,32 @@ type Props = PropsWithLink | PropsWithoutLink
|
||||
|
||||
|
||||
export default (({ tag,
|
||||
nestLevel = 0,
|
||||
linkFlg = true,
|
||||
withWiki = true,
|
||||
withCount = true,
|
||||
...props }: Props) => {
|
||||
const [havingWiki, setHavingWiki] = useState (true)
|
||||
|
||||
const wikiExists = async (tagName: string) => {
|
||||
try
|
||||
{
|
||||
await axios.get (`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (tagName) }/exists`)
|
||||
setHavingWiki (true)
|
||||
}
|
||||
catch
|
||||
{
|
||||
setHavingWiki (false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
if (!(linkFlg) || !(withWiki))
|
||||
return
|
||||
|
||||
wikiExists (tag.name)
|
||||
}, [tag.name, linkFlg, withWiki])
|
||||
|
||||
const spanClass = cn (
|
||||
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
||||
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
|
||||
@@ -37,10 +63,25 @@ export default (({ tag,
|
||||
<>
|
||||
{(linkFlg && withWiki) && (
|
||||
<span className="mr-1">
|
||||
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className={linkClass}>
|
||||
?
|
||||
</Link>
|
||||
{havingWiki
|
||||
? (
|
||||
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className={linkClass}>
|
||||
?
|
||||
</Link>)
|
||||
: (
|
||||
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
||||
title={`${ tag.name } Wiki が存在しません.`}>
|
||||
!
|
||||
</Link>)}
|
||||
</span>)}
|
||||
{nestLevel > 0 && (
|
||||
<span
|
||||
className="ml-1 mr-1"
|
||||
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
||||
↳
|
||||
</span>)}
|
||||
{linkFlg
|
||||
? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,13 +53,13 @@ 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 },
|
||||
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
||||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||
{ name: 'タグのつけ方', to: '/wiki/ヘルプ:タグのつけ方' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
||||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||||
{ name: '検索', to: '/wiki' },
|
||||
@@ -53,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 ()
|
||||
@@ -98,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}/>
|
||||
@@ -124,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>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC, FocusEvent } from 'react'
|
||||
|
||||
|
||||
const pad = (n: number) => n.toString ().padStart (2, '0')
|
||||
|
||||
|
||||
const toDateTimeLocalValue = (d: Date) => {
|
||||
const y = d.getFullYear ()
|
||||
const m = pad (d.getMonth () + 1)
|
||||
const day = pad (d.getDate ())
|
||||
const h = pad (d.getHours ())
|
||||
const min = pad (d.getMinutes ())
|
||||
const s = pad (d.getSeconds ())
|
||||
return `${ y }-${ m }-${ day }T${ h }:${ min }:${ s }`
|
||||
}
|
||||
|
||||
|
||||
type Props = {
|
||||
value?: string
|
||||
onChange?: (isoUTC: string | null) => void
|
||||
className?: string
|
||||
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void }
|
||||
|
||||
|
||||
export default (({ value, onChange, className, onBlur }: Props) => {
|
||||
const [local, setLocal] = useState ('')
|
||||
|
||||
useEffect (() => {
|
||||
setLocal (value ? toDateTimeLocalValue (new Date (value)) : '')
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<input
|
||||
className={cn ('border rounded p-2', className)}
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={local}
|
||||
onChange={ev => {
|
||||
const v = ev.target.value
|
||||
setLocal (v)
|
||||
onChange?.(v ? (new Date (v)).toISOString () : null)
|
||||
}}
|
||||
onBlur={onBlur}/>)
|
||||
}) 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>
|
||||
+15
-1
@@ -1,3 +1,5 @@
|
||||
@import "@fontsource-variable/noto-sans-jp";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -21,7 +23,7 @@
|
||||
|
||||
:root
|
||||
{
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-family: "Noto Sans JP Variable", system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
@@ -94,3 +96,15 @@ button:focus-visible
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wiki-blink
|
||||
{
|
||||
0%, 100% { color: #dc2626; }
|
||||
50% { color: #2563eb; }
|
||||
}
|
||||
|
||||
@keyframes wiki-blink-dark
|
||||
{
|
||||
0%, 100% { color: #f87171; }
|
||||
50% { color: #60a5fa; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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)
|
||||
|
||||
// 投稿列の結合で使用
|
||||
let rowsCnt: number
|
||||
|
||||
useEffect (() => {
|
||||
void (async () => {
|
||||
const res = await axios.get (`${ API_BASE_URL }/posts/changes`,
|
||||
{ params: { ...(id && { id }), page, limit } })
|
||||
const data = toCamel (res.data as any, { deep: true }) as {
|
||||
changes: PostTagChange[]
|
||||
count: number }
|
||||
setChanges (data.changes)
|
||||
setTotalPages (Math.ceil (data.count / limit))
|
||||
}) ()
|
||||
}, [id, page, limit])
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`耕作履歴 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<PageTitle>
|
||||
耕作履歴
|
||||
{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, i) => {
|
||||
let withPost = i === 0 || change.post.id !== changes[i - 1].post.id
|
||||
if (withPost)
|
||||
{
|
||||
rowsCnt = 1
|
||||
for (let j = i + 1;
|
||||
(j < changes.length
|
||||
&& change.post.id === changes[j].post.id);
|
||||
++j)
|
||||
++rowsCnt
|
||||
}
|
||||
return (
|
||||
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}>
|
||||
{withPost && (
|
||||
<td className="align-top" rowSpan={rowsCnt}>
|
||||
<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
|
||||
@@ -7,6 +7,7 @@ import { Link, useLocation, useNavigationType } from 'react-router-dom'
|
||||
import PostList from '@/components/PostList'
|
||||
import TagSidebar from '@/components/TagSidebar'
|
||||
import WikiBody from '@/components/WikiBody'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { API_BASE_URL, SITE_TITLE } from '@/config'
|
||||
@@ -23,6 +24,7 @@ export default () => {
|
||||
const [cursor, setCursor] = useState ('')
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [posts, setPosts] = useState<Post[]> ([])
|
||||
const [totalPages, setTotalPages] = useState (0)
|
||||
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
|
||||
|
||||
const loadMore = async (withCursor: boolean) => {
|
||||
@@ -31,15 +33,19 @@ export default () => {
|
||||
const res = await axios.get (`${ API_BASE_URL }/posts`, {
|
||||
params: { tags: tags.join (' '),
|
||||
match: anyFlg ? 'any' : 'all',
|
||||
limit: '20',
|
||||
...(page && { page }),
|
||||
...(limit && { limit }),
|
||||
...(withCursor && { cursor }) } })
|
||||
const data = toCamel (res.data as any, { deep: true }) as { posts: Post[]
|
||||
nextCursor: string }
|
||||
const data = toCamel (res.data as any, { deep: true }) as {
|
||||
posts: Post[]
|
||||
count: number
|
||||
nextCursor: string }
|
||||
setPosts (posts => (
|
||||
[...((new Map ([...(withCursor ? posts : []), ...data.posts]
|
||||
.map (post => [post.id, post])))
|
||||
.values ())]))
|
||||
setCursor (data.nextCursor)
|
||||
setTotalPages (Math.ceil (data.count / limit))
|
||||
|
||||
setLoading (false)
|
||||
}
|
||||
@@ -49,6 +55,8 @@ export default () => {
|
||||
const tagsQuery = query.get ('tags') ?? ''
|
||||
const anyFlg = query.get ('match') === 'any'
|
||||
const tags = tagsQuery.split (' ').filter (e => e !== '')
|
||||
const page = Number (query.get ('page') ?? 1)
|
||||
const limit = Number (query.get ('limit') ?? 20)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver (entries => {
|
||||
@@ -65,7 +73,8 @@ export default () => {
|
||||
}, [loaderRef, loading])
|
||||
|
||||
useLayoutEffect (() => {
|
||||
const savedState = sessionStorage.getItem (`posts:${ tagsQuery }`)
|
||||
// TODO: 無限ロード用
|
||||
const savedState = /* sessionStorage.getItem (`posts:${ tagsQuery }`) */ null
|
||||
if (savedState && navigationType === 'POP')
|
||||
{
|
||||
const { posts, cursor, scroll } = JSON.parse (savedState)
|
||||
@@ -116,18 +125,23 @@ export default () => {
|
||||
<MainArea>
|
||||
<TabGroup>
|
||||
<Tab name="広場">
|
||||
{posts.length
|
||||
{posts.length > 0
|
||||
? (
|
||||
<PostList posts={posts} onClick={() => {
|
||||
const statesToSave = {
|
||||
posts, cursor,
|
||||
scroll: containerRef.current?.scrollTop ?? 0 }
|
||||
sessionStorage.setItem (`posts:${ tagsQuery }`,
|
||||
JSON.stringify (statesToSave))
|
||||
}}/>)
|
||||
<>
|
||||
<PostList posts={posts} onClick={() => {
|
||||
// TODO: 無限ロード用なので復活時に戻す.
|
||||
// const statesToSave = {
|
||||
// posts, cursor,
|
||||
// scroll: containerRef.current?.scrollTop ?? 0 }
|
||||
// sessionStorage.setItem (`posts:${ tagsQuery }`,
|
||||
// JSON.stringify (statesToSave))
|
||||
}}/>
|
||||
<Pagination page={page} totalPages={totalPages}/>
|
||||
</>)
|
||||
: !(loading) && '広場には何もありませんよ.'}
|
||||
{loading && 'Loading...'}
|
||||
<div ref={loaderRef} className="h-12"/>
|
||||
{/* TODO: 無限ローディング復活までコメント・アウト */}
|
||||
{/* <div ref={loaderRef} className="h-12"/> */}
|
||||
</Tab>
|
||||
{tags.length === 1 && (
|
||||
<Tab name="Wiki">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import Form from '@/components/common/Form'
|
||||
import Label from '@/components/common/Label'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
@@ -26,15 +27,17 @@ export default (({ user }: Props) => {
|
||||
|
||||
const navigate = useNavigate ()
|
||||
|
||||
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
|
||||
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
||||
const [tags, setTags] = useState ('')
|
||||
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState (false)
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
|
||||
const [title, setTitle] = useState ('')
|
||||
const [titleAutoFlg, setTitleAutoFlg] = useState (true)
|
||||
const [titleLoading, setTitleLoading] = useState (false)
|
||||
const [url, setURL] = useState ('')
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
|
||||
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState (false)
|
||||
const [tags, setTags] = useState ('')
|
||||
|
||||
const previousURLRef = useRef ('')
|
||||
|
||||
@@ -45,6 +48,10 @@ export default (({ user }: Props) => {
|
||||
formData.append ('tags', tags)
|
||||
if (thumbnailFile)
|
||||
formData.append ('thumbnail', thumbnailFile)
|
||||
if (originalCreatedFrom)
|
||||
formData.append ('original_created_from', originalCreatedFrom)
|
||||
if (originalCreatedBefore)
|
||||
formData.append ('original_created_before', originalCreatedBefore)
|
||||
|
||||
try
|
||||
{
|
||||
@@ -122,7 +129,7 @@ export default (({ user }: Props) => {
|
||||
{/* URL */}
|
||||
<div>
|
||||
<Label>URL</Label>
|
||||
<input type="text"
|
||||
<input type="url"
|
||||
placeholder="例:https://www.nicovideo.jp/watch/..."
|
||||
value={url}
|
||||
onChange={e => setURL (e.target.value)}
|
||||
@@ -181,6 +188,13 @@ export default (({ user }: Props) => {
|
||||
{/* タグ */}
|
||||
<PostFormTagsArea tags={tags} setTags={setTags}/>
|
||||
|
||||
{/* オリジナルの作成日時 */}
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom={originalCreatedFrom}
|
||||
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
||||
originalCreatedBefore={originalCreatedBefore}
|
||||
setOriginalCreatedBefore={setOriginalCreatedBefore}/>
|
||||
|
||||
{/* 送信 */}
|
||||
<Button onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
||||
|
||||
@@ -36,9 +36,16 @@ export default () => {
|
||||
if (/^\d+$/.test (title))
|
||||
{
|
||||
void (async () => {
|
||||
const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`)
|
||||
const data = res.data as WikiPage
|
||||
navigate (`/wiki/${ data.title }`, { replace: true })
|
||||
try
|
||||
{
|
||||
const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`)
|
||||
const data = res.data as WikiPage
|
||||
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
}
|
||||
}) ()
|
||||
|
||||
return
|
||||
@@ -51,6 +58,8 @@ export default () => {
|
||||
`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`,
|
||||
{ params: version ? { version } : { } })
|
||||
const data = toCamel (res.data as any, { deep: true }) as WikiPage
|
||||
if (data.title !== title)
|
||||
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
|
||||
setWikiPage (data)
|
||||
WikiIdBus.set (data.id)
|
||||
}
|
||||
|
||||
+28
-18
@@ -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[]
|
||||
@@ -17,28 +17,38 @@ export type NicoTag = Tag & {
|
||||
linkedTags: Tag[] }
|
||||
|
||||
export type Post = {
|
||||
id: number
|
||||
url: string
|
||||
title: string
|
||||
thumbnail: string
|
||||
thumbnailBase: string
|
||||
tags: Tag[]
|
||||
viewed: boolean
|
||||
related: Post[] }
|
||||
id: number
|
||||
url: string
|
||||
title: string
|
||||
thumbnail: string
|
||||
thumbnailBase: string
|
||||
tags: Tag[]
|
||||
viewed: boolean
|
||||
related: Post[]
|
||||
createdAt: string
|
||||
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
|
||||
|
||||
+19
-28
@@ -2,35 +2,26 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
import { DARK_COLOUR_SHADE,
|
||||
LIGHT_COLOUR_SHADE,
|
||||
TAG_COLOUR } from './src/consts'
|
||||
LIGHT_COLOUR_SHADE,
|
||||
TAG_COLOUR } from './src/consts'
|
||||
|
||||
const colours = Object.values (TAG_COLOUR)
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,ts,jsx,tsx}'],
|
||||
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
|
||||
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),
|
||||
...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`),
|
||||
...colours.map (c => `dark:hover:text-${ c }-${ DARK_COLOUR_SHADE - 200 }`)],
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'rainbow-scroll': 'rainbow-scroll .25s linear infinite',
|
||||
},
|
||||
colors: {
|
||||
red: {
|
||||
925: '#5f1414',
|
||||
975: '#230505',
|
||||
}
|
||||
},
|
||||
keyframes: {
|
||||
'rainbow-scroll': {
|
||||
'0%': { backgroundPosition: '0% 50%' },
|
||||
'100%': { backgroundPosition: '200% 50%' },
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config
|
||||
content: ['./src/**/*.{html,js,ts,jsx,tsx}'],
|
||||
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
|
||||
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),
|
||||
...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`),
|
||||
...colours.map (c => `dark:hover:text-${ c }-${ DARK_COLOUR_SHADE - 200 }`)],
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'rainbow-scroll': 'rainbow-scroll .25s linear infinite' },
|
||||
colors: {
|
||||
red: { 925: '#5f1414',
|
||||
975: '#230505' } },
|
||||
keyframes: {
|
||||
'rainbow-scroll': {
|
||||
'0%': { backgroundPosition: '0% 50%' },
|
||||
'100%': { backgroundPosition: '200% 50%' } } } } },
|
||||
plugins: [] } satisfies Config
|
||||
|
||||
新しい課題から参照
ユーザをブロックする