diff --git a/backend/Gemfile b/backend/Gemfile
index bb5460b..303b937 100644
--- a/backend/Gemfile
+++ b/backend/Gemfile
@@ -63,3 +63,5 @@ gem 'diff-lcs'
gem 'dotenv-rails'
gem 'whenever', require: false
+
+gem 'discard'
diff --git a/backend/Gemfile.lock b/backend/Gemfile.lock
index 8494a53..2c08f92 100644
--- a/backend/Gemfile.lock
+++ b/backend/Gemfile.lock
@@ -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)
diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb
index 73f3995..ce555ed 100644
--- a/backend/app/controllers/posts_controller.rb
+++ b/backend/app/controllers/posts_controller.rb
@@ -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
+
+ offset = (page - 1) * limit
- posts = limit ? q.limit(limit + 1) : q
+ 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(tags_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
diff --git a/backend/app/controllers/users_controller.rb b/backend/app/controllers/users_controller.rb
index 8658a5f..4ee4836 100644
--- a/backend/app/controllers/users_controller.rb
+++ b/backend/app/controllers/users_controller.rb
@@ -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
diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb
index a778b48..abe29f0 100644
--- a/backend/app/controllers/wiki_pages_controller.rb
+++ b/backend/app/controllers/wiki_pages_controller.rb
@@ -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]
diff --git a/backend/app/models/nico_tag_relation.rb b/backend/app/models/nico_tag_relation.rb
index ff4f3a6..d2c4a82 100644
--- a/backend/app/models/nico_tag_relation.rb
+++ b/backend/app/models/nico_tag_relation.rb
@@ -6,6 +6,7 @@ class NicoTagRelation < ApplicationRecord
validates :tag_id, presence: true
validate :nico_tag_must_be_nico
+ validate :tag_mustnt_be_nico
private
diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb
index bdc136a..6dd565b 100644
--- a/backend/app/models/post.rb
+++ b/backend/app/models/post.rb
@@ -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
diff --git a/backend/app/models/post_tag.rb b/backend/app/models/post_tag.rb
index 9dbd756..91a739d 100644
--- a/backend/app/models/post_tag.rb
+++ b/backend/app/models/post_tag.rb
@@ -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
diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb
index 137afff..d496802 100644
--- a/backend/app/models/tag.rb
+++ b/backend/app/models/tag.rb
@@ -1,6 +1,8 @@
class Tag < ApplicationRecord
- has_many :post_tags, dependent: :destroy
- has_many :posts, through: :post_tags
+ has_many :post_tags, dependent: :delete_all, inverse_of: :tag
+ has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
+ has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
+ has_many :posts, through: :active_post_tags
has_many :tag_aliases, dependent: :destroy
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
@@ -11,6 +13,14 @@ class Tag < ApplicationRecord
dependent: :destroy
has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag
+ has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
+ has_many :children, through: :tag_implications, source: :tag
+
+ has_many :reversed_tag_implications, class_name: 'TagImplication',
+ foreign_key: :tag_id,
+ dependent: :destroy
+ has_many :parents, through: :reversed_tag_implications, source: :parent_tag
+
enum :category, { deerjikist: 'deerjikist',
meme: 'meme',
character: 'character',
@@ -35,13 +45,13 @@ class Tag < ApplicationRecord
'meta:' => 'meta' }.freeze
def self.tagme
- @tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag|
+ @tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag|
tag.category = 'meta'
end
end
def self.bot
- @bot ||= Tag.find_or_initialize_by(name: 'bot操作') do |tag|
+ @bot ||= Tag.find_or_create_by!(name: 'bot操作') do |tag|
tag.category = 'meta'
end
end
@@ -57,10 +67,33 @@ class Tag < ApplicationRecord
end
end
end
- tags << Tag.tagme if with_tagme && tags.size < 20 && tags.none?(Tag.tagme)
+ tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags.uniq
end
+ def self.expand_parent_tags tags
+ return [] if tags.blank?
+
+ seen = Set.new
+ result = []
+ stack = tags.compact.dup
+
+ until stack.empty?
+ tag = stack.pop
+ next unless tag
+
+ tag.parents.each do |parent|
+ next if seen.include?(parent.id)
+
+ seen << parent.id
+ result << parent
+ stack << parent
+ end
+ end
+
+ (result + tags).uniq { |t| t.id }
+ end
+
private
def nico_tag_name_must_start_with_nico
diff --git a/backend/app/models/tag_implication.rb b/backend/app/models/tag_implication.rb
new file mode 100644
index 0000000..a629764
--- /dev/null
+++ b/backend/app/models/tag_implication.rb
@@ -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
diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb
index 830d383..ede464a 100644
--- a/backend/app/models/user.rb
+++ b/backend/app/models/user.rb
@@ -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
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 709f1d0..206d1a6 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -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 :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 :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
- # 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"
+ resources :ip_addresses
+ resources :nico_tag_relations
+ resources :post_tags
+ resources :settings
+ resources :tag_aliases
+ resources :user_ips
+ resources :user_post_views
end
diff --git a/backend/config/schedule.rb b/backend/config/schedule.rb
index 7ba2687..b4db72a 100644
--- a/backend/config/schedule.rb
+++ b/backend/config/schedule.rb
@@ -6,3 +6,7 @@ set :output, standard: '/var/log/btrc_hub_nico_sync.log',
every 1.day, at: '3:00 pm' do
rake 'nico:sync', environment: 'production'
end
+
+every 1.day, at: '0:00 am' do
+ rake 'post_similarity:calc', environment: 'production'
+end
diff --git a/backend/db/migrate/20250909075500_add_original_created_at_to_posts.rb b/backend/db/migrate/20250909075500_add_original_created_at_to_posts.rb
new file mode 100644
index 0000000..161892c
--- /dev/null
+++ b/backend/db/migrate/20250909075500_add_original_created_at_to_posts.rb
@@ -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
diff --git a/backend/db/migrate/20251009222200_create_tag_implications.rb b/backend/db/migrate/20251009222200_create_tag_implications.rb
new file mode 100644
index 0000000..ea8df1a
--- /dev/null
+++ b/backend/db/migrate/20251009222200_create_tag_implications.rb
@@ -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
diff --git a/backend/db/migrate/20251011200300_add_discard_to_post_tags.rb b/backend/db/migrate/20251011200300_add_discard_to_post_tags.rb
new file mode 100644
index 0000000..f825a37
--- /dev/null
+++ b/backend/db/migrate/20251011200300_add_discard_to_post_tags.rb
@@ -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
diff --git a/backend/db/migrate/20251126231500_rename_ip_adress_column_to_ip_addresses.rb b/backend/db/migrate/20251126231500_rename_ip_adress_column_to_ip_addresses.rb
new file mode 100644
index 0000000..60f78e6
--- /dev/null
+++ b/backend/db/migrate/20251126231500_rename_ip_adress_column_to_ip_addresses.rb
@@ -0,0 +1,5 @@
+class RenameIpAdressColumnToIpAddresses < ActiveRecord::Migration[8.0]
+ def change
+ rename_column :ip_addresses, :ip_adress, :ip_address
+ end
+end
diff --git a/backend/db/migrate/20251210123200_add_unique_index_to_tag_implications.rb b/backend/db/migrate/20251210123200_add_unique_index_to_tag_implications.rb
new file mode 100644
index 0000000..681c1b5
--- /dev/null
+++ b/backend/db/migrate/20251210123200_add_unique_index_to_tag_implications.rb
@@ -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
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index f339414..6a26dfd 100644
--- a/backend/db/schema.rb
+++ b/backend/db/schema.rb
@@ -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"
diff --git a/backend/lib/tasks/link_nico.rake b/backend/lib/tasks/link_nico.rake
deleted file mode 100644
index 0c02f48..0000000
--- a/backend/lib/tasks/link_nico.rake
+++ /dev/null
@@ -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
diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake
index a8601c5..d20150c 100644
--- a/backend/lib/tasks/sync_nico.rake
+++ b/backend/lib/tasks/sync_nico.rake
@@ -5,12 +5,32 @@ namespace :nico do
require 'open-uri'
require 'nokogiri'
- fetch_thumbnail = -> url {
+ fetch_thumbnail = -> url do
html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read
doc = Nokogiri::HTML(html)
doc.at('meta[name="thumbnail"]')&.[]('content').presence
- }
+ end
+
+ def sync_post_tags! post, desired_tag_ids
+ desired_ids = desired_tag_ids.compact.to_set
+ current_ids = post.tags.pluck(:id).to_set
+
+ to_add = desired_ids - current_ids
+ to_remove = current_ids - desired_ids
+
+ Tag.where(id: to_add.to_a).find_each do |tag|
+ begin
+ PostTag.create!(post:, tag:)
+ rescue ActiveRecord::RecordNotUnique
+ ;
+ end
+ end
+
+ PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
+ pt.discard_by!(nil)
+ end
+ end
mysql_user = ENV['MYSQL_USER']
mysql_pass = ENV['MYSQL_PASS']
@@ -19,43 +39,57 @@ namespace :nico do
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
'python3', "#{ nizika_nico_path }/get_videos.py")
- if status.success?
- data = JSON.parse(stdout)
- data.each do |datum|
- post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post|
- post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)}
- }
- unless post
- title = datum['title']
- url = "https://www.nicovideo.jp/watch/#{ datum['code'] }"
- thumbnail_base = fetch_thumbnail.(url) || '' rescue ''
- post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil)
- if thumbnail_base.present?
- post.thumbnail.attach(
- io: URI.open(thumbnail_base),
- filename: File.basename(URI.parse(thumbnail_base).path),
- content_type: 'image/jpeg')
- end
- post.save!
- post.resized_thumbnail!
+ abort unless status.success?
+
+ data = JSON.parse(stdout)
+ data.each do |datum|
+ post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post|
+ post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)}
+ }
+ unless post
+ title = datum['title']
+ url = "https://www.nicovideo.jp/watch/#{ datum['code'] }"
+ thumbnail_base = fetch_thumbnail.(url) || '' rescue ''
+ post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil)
+ if thumbnail_base.present?
+ post.thumbnail.attach(
+ io: URI.open(thumbnail_base),
+ filename: File.basename(URI.parse(thumbnail_base).path),
+ content_type: 'image/jpeg')
end
+ post.save!
+ post.resized_thumbnail!
+ sync_post_tags!(post, [Tag.tagme.id])
+ end
+
+ kept_tags = post.tags.reload
+ kept_non_nico_ids = kept_tags.where.not(category: 'nico').pluck(:id).to_set
- current_tags = post.tags.where(category: 'nico').pluck(:name).sort
- new_tags = datum['tags'].map { |tag| "nico:#{ tag }" }.sort
- if current_tags != new_tags
- post.tags.destroy(post.tags.where(name: current_tags))
- tags_to_add = []
- new_tags.each do |name|
- tag = Tag.find_or_initialize_by(name:) do |t|
- t.category = 'nico'
- end
- tags_to_add.concat([tag] + tag.linked_tags)
- end
- tags_to_add << Tag.tagme if post.tags.size < 20
- tags_to_add << Tag.bot
- post.tags = (post.tags + tags_to_add).uniq
+ desired_nico_ids = []
+ desired_non_nico_ids = []
+ datum['tags'].each do |raw|
+ name = "nico:#{ raw }"
+ tag = Tag.find_or_initialize_by(name:) do |t|
+ t.category = 'nico'
end
+ tag.save! if tag.new_record?
+ desired_nico_ids << tag.id
+ unless tag.in?(kept_tags)
+ desired_non_nico_ids.concat(tag.linked_tags.pluck(:id))
+ desired_nico_ids.concat(tag.linked_tags.pluck(:id))
+ end
+ end
+ desired_nico_ids.uniq!
+
+ desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids
+ desired_non_nico_ids.concat(kept_non_nico_ids.to_a)
+ desired_non_nico_ids.uniq!
+ if kept_non_nico_ids.to_set != desired_non_nico_ids.to_set
+ desired_all_ids << Tag.bot.id
end
+ desired_all_ids.uniq!
+
+ sync_post_tags!(post, desired_all_ids)
end
end
end
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 417dc54..43fbc44 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
@@ -25,6 +27,8 @@
"react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^6.30.0",
+ "react-youtube": "^10.1.0",
+ "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
@@ -945,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",
@@ -3375,7 +3388,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -3562,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",
@@ -4168,6 +4207,12 @@
"uc.micro": "^2.0.0"
}
},
+ "node_modules/load-script": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
+ "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==",
+ "license": "MIT"
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4261,6 +4306,16 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4270,6 +4325,34 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/mdast-util-from-markdown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -4294,6 +4377,107 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -4508,6 +4692,127 @@
"micromark-util-types": "^2.0.0"
}
},
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -4939,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",
@@ -5014,7 +5334,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5389,6 +5708,17 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -5498,6 +5828,12 @@
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
}
},
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -5651,6 +5987,23 @@
}
}
},
+ "node_modules/react-youtube": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-10.1.0.tgz",
+ "integrity": "sha512-ZfGtcVpk0SSZtWCSTYOQKhfx5/1cfyEW1JN/mugGNfAxT3rmVJeMbGpA9+e78yG21ls5nc/5uZJETE3cm3knBg==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "3.1.3",
+ "prop-types": "15.8.1",
+ "youtube-player": "5.5.2"
+ },
+ "engines": {
+ "node": ">= 14.x"
+ },
+ "peerDependencies": {
+ "react": ">=0.14.1"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -5674,6 +6027,24 @@
"node": ">=8.10.0"
}
},
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -5707,6 +6078,21 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -5871,6 +6257,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/sister": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sister/-/sister-3.0.2.tgz",
+ "integrity": "sha512-p19rtTs+NksBRKW9qn0UhZ8/TUI9BPw9lmtHny+Y3TinWlOa9jWh9xB0AtPSdmOy49NJJJSSe0Ey4C7h0TrcYA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6811,6 +7203,32 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/youtube-player": {
+ "version": "5.5.2",
+ "resolved": "https://registry.npmjs.org/youtube-player/-/youtube-player-5.5.2.tgz",
+ "integrity": "sha512-ZGtsemSpXnDky2AUYWgxjaopgB+shFHgXVpiJFeNB5nWEugpW1KWYDaHKuLqh2b67r24GtP6HoSW5swvf0fFIQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "debug": "^2.6.6",
+ "load-script": "^1.0.0",
+ "sister": "^3.0.0"
+ }
+ },
+ "node_modules/youtube-player/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/youtube-player/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 7986d56..cbe44ff 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
@@ -27,6 +29,8 @@
"react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^6.30.0",
+ "react-youtube": "^10.1.0",
+ "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
diff --git a/frontend/scripts/generate-sitemap.js b/frontend/scripts/generate-sitemap.js
index f30786c..bf6c9fe 100644
--- a/frontend/scripts/generate-sitemap.js
+++ b/frontend/scripts/generate-sitemap.js
@@ -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 => `
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d54981b..2195ca8 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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'
@@ -20,10 +21,12 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import WikiNewPage from '@/pages/wiki/WikiNewPage'
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
+import type { FC } from 'react'
+
import type { User } from '@/types'
-export default () => {
+export default (() => {
const [user, setUser] = useState (null)
const [status, setStatus] = useState (200)
@@ -65,30 +68,31 @@ export default () => {
switch (status)
{
case 503:
- return
+ return
}
return (
-
+
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
-
+
)
-}
+}) satisfies FC
diff --git a/frontend/src/components/ErrorScreen.tsx b/frontend/src/components/ErrorScreen.tsx
index 651a81b..b77c7f9 100644
--- a/frontend/src/components/ErrorScreen.tsx
+++ b/frontend/src/components/ErrorScreen.tsx
@@ -5,10 +5,12 @@ import errorImg from '@/assets/images/not-found.gif'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
+import type { FC } from 'react'
+
type Props = { status: number }
-export default ({ status }: Props) => {
+export default (({ status }: Props) => {
const [message, rightMsg, leftMsg]: [string, string, string] = (() => {
switch (status)
{
@@ -39,7 +41,7 @@ export default ({ status }: Props) => {
return (
-
+
{title} | {SITE_TITLE}
{leftMsg}
-

+
{rightMsg}
{message}
)
-}
+}) satisfies FC
diff --git a/frontend/src/components/MenuSeparator.tsx b/frontend/src/components/MenuSeparator.tsx
index 43b94a7..8c7b5d3 100644
--- a/frontend/src/components/MenuSeparator.tsx
+++ b/frontend/src/components/MenuSeparator.tsx
@@ -1,6 +1,9 @@
-export default () => (
+import type { FC } from 'react'
+
+
+export default (() => (
<>
|
- >)
+ border-t border-black dark:border-white"/>
+ >)) satisfies FC
diff --git a/frontend/src/components/NicoViewer.tsx b/frontend/src/components/NicoViewer.tsx
index 71314f5..a71acb5 100644
--- a/frontend/src/components/NicoViewer.tsx
+++ b/frontend/src/components/NicoViewer.tsx
@@ -4,10 +4,10 @@ type Props = { id: string,
height: number,
style?: CSSProperties }
-import type { CSSProperties } from 'react'
+import type { CSSProperties, FC } from 'react'
-export default (props: Props) => {
+export default ((props: Props) => {
const { id, width, height, style = { } } = props
const iframeRef = useRef (null)
@@ -107,5 +107,5 @@ export default (props: Props) => {
height={height}
style={margedStyle}
allowFullScreen
- allow="autoplay" />)
-}
+ allow="autoplay"/>)
+}) satisfies FC
diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx
index b9a7adb..38b03b4 100644
--- a/frontend/src/components/PostEditForm.tsx
+++ b/frontend/src/components/PostEditForm.tsx
@@ -1,53 +1,85 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
-import TextArea from '@/components/common/TextArea'
+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 { Post } from '@/types'
+import type { FC } from 'react'
-type Props = { post: Post
- onSave: (newPost: Post) => void }
+import type { Post, Tag } from '@/types'
-export default ({ post, onSave }: Props) => {
+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 (post.originalCreatedBefore)
+ const [originalCreatedFrom, setOriginalCreatedFrom] =
+ useState (post.originalCreatedFrom)
const [title, setTitle] = useState (post.title)
- const [tags, setTags] = useState (post.tags
- .filter (t => t.category !== 'nico')
- .map (t => t.name)
- .join (' '))
+ const [tags, setTags] = useState ('')
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 (
{/* タイトル */}
-
-
-
+
setTitle (e.target.value)} />
+ onChange={ev => setTitle (ev.target.value)}/>
{/* タグ */}
-
-
-
+
+
+ {/* オリジナルの作成日時 */}
+
{/* 送信 */}
)
-}
+}) satisfies FC
diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx
new file mode 100644
index 0000000..abb0fbe
--- /dev/null
+++ b/frontend/src/components/PostEmbed.tsx
@@ -0,0 +1,48 @@
+import YoutubeEmbed from 'react-youtube'
+
+import NicoViewer from '@/components/NicoViewer'
+import TwitterEmbed from '@/components/TwitterEmbed'
+
+import type { FC } from 'react'
+
+import type { Post } from '@/types'
+
+type Props = { post: Post }
+
+
+export default (({ post }: Props) => {
+ const url = new URL (post.url)
+
+ switch (url.hostname.split ('.').slice (-2).join ('.'))
+ {
+ case 'nicovideo.jp':
+ {
+ const [videoId] = url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)!
+ return
+ }
+ case 'twitter.com':
+ case 'x.com':
+ const [userId] = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/)!
+ const [statusId] = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)!
+ return
+ case 'youtube.com':
+ {
+ const videoId = url.searchParams.get ('v')!
+ return (
+ )
+ }
+ }
+
+ return (
+
+
+ )
+}) satisfies FC
diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx
new file mode 100644
index 0000000..38ffd70
--- /dev/null
+++ b/frontend/src/components/PostFormTagsArea.tsx
@@ -0,0 +1,84 @@
+import axios from 'axios'
+import toCamel from 'camelcase-keys'
+import { useRef, useState } from 'react'
+
+import TagSearchBox from '@/components/TagSearchBox'
+import Label from '@/components/common/Label'
+import TextArea from '@/components/common/TextArea'
+import { API_BASE_URL } from '@/config'
+
+import type { FC, SyntheticEvent } from 'react'
+
+import type { Tag } from '@/types'
+
+const SEP = /\s/
+
+
+const getTokenAt = (value: string, pos: number) => {
+ let start = pos
+ while (start > 0 && !(SEP.test (value[start - 1])))
+ --start
+
+ let end = pos
+ while (end < value.length && !(SEP.test (value[end])))
+ ++end
+
+ return { start, end, token: value.slice (start, end) }
+}
+
+
+const replaceToken = (value: string, start: number, end: number, text: string) => (
+ `${ value.slice (0, start) }${ text }${ value.slice (end) }`)
+
+
+type Props = {
+ tags: string
+ setTags: (tags: string) => void }
+
+
+export default (({ tags, setTags }: Props) => {
+ const ref = useRef (null)
+
+ const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
+ const [suggestions, setSuggestions] = useState ([])
+ const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
+
+ const handleTagSelect = (tag: Tag) => {
+ setSuggestionsVsbl (false)
+ const textarea = ref.current!
+ const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name)
+ setTags (newValue)
+ requestAnimationFrame (async () => {
+ const p = bounds.start + tag.name.length
+ textarea.selectionStart = textarea.selectionEnd = p
+ textarea.focus ()
+ await recompute (p, newValue)
+ })
+ }
+
+ const recompute = async (pos: number, v: string = tags) => {
+ const { start, end, token } = getTokenAt (v, pos)
+ setBounds ({ start, end })
+ const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q: token } })
+ setSuggestions (toCamel (res.data as any, { deep: true }) as Tag[])
+ setSuggestionsVsbl (suggestions.length > 0)
+ }
+
+ return (
+
+
+
)
+}) satisfies FC
diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx
index 10fdd29..67a3a28 100644
--- a/frontend/src/components/PostList.tsx
+++ b/frontend/src/components/PostList.tsx
@@ -1,25 +1,25 @@
import { Link } from 'react-router-dom'
-import type { MouseEvent } from 'react'
+import type { FC, MouseEvent } from 'react'
+
import type { Post } from '@/types'
type Props = { posts: Post[]
onClick?: (event: MouseEvent) => void }
-export default ({ posts, onClick }: Props) => (
+export default (({ posts, onClick }: Props) => (
{posts.map ((post, i) => (

+ className="object-cover w-full h-full"/>
))}
-
)
+ )) satisfies FC
diff --git a/frontend/src/components/PostOriginalCreatedTimeField.tsx b/frontend/src/components/PostOriginalCreatedTimeField.tsx
new file mode 100644
index 0000000..ffabc6b
--- /dev/null
+++ b/frontend/src/components/PostOriginalCreatedTimeField.tsx
@@ -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) => (
+
+
+
+ {
+ 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 ())
+ }}/>
+ 以降
+
+
+
+ より前
+
+
)) satisfies FC
diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx
index 66afc78..a897fa8 100644
--- a/frontend/src/components/TagDetailSidebar.tsx
+++ b/frontend/src/components/TagDetailSidebar.tsx
@@ -1,19 +1,49 @@
+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, 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 = (
+
+
+ )
+
+ 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 }
-export default ({ post }: Props) => {
+export default (({ post }: Props) => {
const [tags, setTags] = useState ({ } as TagByCategory)
const categoryNames: Record = {
@@ -46,16 +76,64 @@ export default ({ post }: Props) => {
return (
-
- {CATEGORIES.map ((cat: Category) => cat in tags && (
-
-
{categoryNames[cat]}
-
- {tags[cat].map ((tag, i) => (
- -
-
-
))}
-
-
))}
+
+
+ {CATEGORIES.map ((cat: Category) => cat in tags && (
+
+ {categoryNames[cat]}
+
+
+
+ {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
+
+
+ ))}
+ {post && (
+
+
情報
+
+ - Id.: {post.id}
+ {/* TODO: uploadedUser の取得を対応したらコメント外す */}
+ {/*
+ -
+ <>耕作者: >
+ {post.uploadedUser
+ ? (
+
+ {post.uploadedUser.name || '名もなきニジラー'}
+ )
+ : 'bot操作'}
+
+ */}
+ - 耕作日時: {(new Date (post.createdAt)).toLocaleString ()}
+ -
+ <>リンク: >
+
+ {post.url}
+
+
+ -
+ {/* TODO: 表示形式きしょすぎるので何とかする */}
+ <>オリジナルの投稿日時: >
+ {!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
+ ? '不明'
+ : (
+ <>
+ {post.originalCreatedFrom
+ && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
+ {post.originalCreatedBefore
+ && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
+ >)}
+
+ -
+ 履歴
+
+
+
)}
+
)
-}
+}) satisfies FC
diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx
index 881ce03..ee673cc 100644
--- a/frontend/src/components/TagLink.tsx
+++ b/frontend/src/components/TagLink.tsx
@@ -1,13 +1,17 @@
+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'
-import type { ComponentProps, HTMLAttributes } from 'react'
+import type { ComponentProps, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types'
type CommonProps = { tag: Tag
+ nestLevel?: number
withWiki?: boolean
withCount?: boolean }
@@ -20,11 +24,33 @@ type PropsWithoutLink =
type Props = PropsWithLink | PropsWithoutLink
-export default ({ tag,
- linkFlg = true,
- withWiki = true,
- withCount = true,
- ...props }: Props) => {
+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) && (
-
- ?
-
+ {havingWiki
+ ? (
+
+ ?
+ )
+ : (
+
+ !
+ )}
+ )}
+ {nestLevel > 0 && (
+
+ ↳
)}
{linkFlg
? (
@@ -57,4 +98,4 @@ export default ({ tag,
{withCount && (
{tag.postCount})}
>)
-}
+}) satisfies FC
diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx
index fc99a60..55ac949 100644
--- a/frontend/src/components/TagSearch.tsx
+++ b/frontend/src/components/TagSearch.tsx
@@ -6,16 +6,18 @@ import { API_BASE_URL } from '@/config'
import TagSearchBox from './TagSearchBox'
+import type { FC } from 'react'
+
import type { Tag } from '@/types'
-const TagSearch: React.FC = () => {
+export default (() => {
const location = useLocation ()
const navigate = useNavigate ()
+ const [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('')
const [suggestions, setSuggestions] = useState ([])
- const [activeIndex, setActiveIndex] = useState (-1)
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const whenChanged = async (ev: React.ChangeEvent) => {
@@ -24,14 +26,14 @@ const TagSearch: React.FC = () => {
const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
- setSuggestions ([])
- return
+ setSuggestions ([])
+ return
}
const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } })
const data = res.data as Tag[]
setSuggestions (data)
- if (suggestions.length)
+ if (suggestions.length > 0)
setSuggestionsVsbl (true)
}
@@ -52,7 +54,7 @@ const TagSearch: React.FC = () => {
case 'Enter':
if (activeIndex < 0)
- break
+ break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
@@ -65,8 +67,8 @@ const TagSearch: React.FC = () => {
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
- navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`)
- setSuggestionsVsbl (false)
+ navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`)
+ setSuggestionsVsbl (false)
}
}
@@ -86,18 +88,16 @@ const TagSearch: React.FC = () => {
return (
- setSuggestionsVsbl (true)}
- onBlur={() => setSuggestionsVsbl (false)}
- onKeyDown={handleKeyDown}
- className="w-full px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-800 dark:text-white" />
-
+ setSuggestionsVsbl (true)}
+ onBlur={() => setSuggestionsVsbl (false)}
+ onKeyDown={handleKeyDown}
+ className="w-full px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-800 dark:text-white"/>
+
)
-}
-
-export default TagSearch
+}) satisfies FC
diff --git a/frontend/src/components/TagSearchBox.tsx b/frontend/src/components/TagSearchBox.tsx
index 381d908..79c912f 100644
--- a/frontend/src/components/TagSearchBox.tsx
+++ b/frontend/src/components/TagSearchBox.tsx
@@ -1,5 +1,7 @@
import { cn } from '@/lib/utils'
+import type { FC } from 'react'
+
import type { Tag } from '@/types'
type Props = { suggestions: Tag[]
@@ -7,8 +9,8 @@ type Props = { suggestions: Tag[]
onSelect: (tag: Tag) => void }
-export default ({ suggestions, activeIndex, onSelect }: Props) => {
- if (!(suggestions.length))
+export default (({ suggestions, activeIndex, onSelect }: Props) => {
+ if (suggestions.length === 0)
return
return (
@@ -19,10 +21,9 @@ export default ({ suggestions, activeIndex, onSelect }: Props) => {
onSelect (tag)}
- >
+ onMouseDown={() => onSelect (tag)}>
{tag.name}
{{tag.postCount}}
))}
)
-}
+}) satisfies FC
diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx
index a985058..b8f5f07 100644
--- a/frontend/src/components/TagSidebar.tsx
+++ b/frontend/src/components/TagSidebar.tsx
@@ -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,8 @@ 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'
import type { Post, Tag } from '@/types'
@@ -17,7 +19,7 @@ type TagByCategory = Record
type Props = { posts: Post[] }
-export default ({ posts }: Props) => {
+export default (({ posts }: Props) => {
const navigate = useNavigate ()
const [tagsVsbl, setTagsVsbl] = useState (false)
@@ -56,49 +58,73 @@ export default ({ posts }: Props) => {
setTags (tagsTmp)
}, [posts])
+ const TagBlock = (
+ <>
+ タグ
+
+ {CATEGORIES.flatMap (cat => cat in tags ? (
+ tags[cat].map (tag => (
+ -
+
+
))) : [])}
+
+ 関聯
+ {posts.length > 0 && (
+ {
+ 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
+ {
+ ;
+ }
+ }) ())
+ }}>
+ ランダム
+ )}
+ >)
+
return (
-
-
-
タグ
-
- {CATEGORIES.flatMap (cat => cat in tags ? (
- tags[cat].map (tag => (
- -
-
-
))) : [])}
-
-
関聯
- {posts.length > 0 && (
-
{
- 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
- {
- ;
- }
- }) ())
- }}>
- ランダム
- )}
+
+
+
+ {TagBlock}
+
+
+ {tagsVsbl && (
+
+ {TagBlock}
+ )}
+
+
{
ev.preventDefault ()
- setTagsVsbl (!(tagsVsbl))
+ setTagsVsbl (v => !(v))
}}>
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
)
-}
+}) satisfies FC
diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx
index 376d94d..9762a3d 100644
--- a/frontend/src/components/TopNav.tsx
+++ b/frontend/src/components/TopNav.tsx
@@ -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'
@@ -9,14 +10,38 @@ import { API_BASE_URL } from '@/config'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { cn } from '@/lib/utils'
+import type { FC } from 'react'
+
import type { Menu, Tag, User, WikiPage } from '@/types'
type Props = { user: User | null }
-export default ({ user }: Props) => {
+export default (({ user }: Props) => {
const location = useLocation ()
+ const dirRef = useRef<(-1) | 1> (1)
+ const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
+ const navRef = useRef (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 (null)
@@ -28,20 +53,20 @@ 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' },
{ name: '新規', to: '/wiki/new' },
{ name: '全体履歴', to: '/wiki/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
- { component: , visible: wikiPageFlg },
+ { component: , visible: wikiPageFlg },
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
@@ -51,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 (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 ()
@@ -96,19 +147,29 @@ export default ({ user }: Props) => {
ぼざクリ タグ広場
- {menu.map ((item, i) => (
-
- {item.name}
-
- ))}
+
+
+
+ {menu.map ((item, i) => (
+
{
+ itemsRef.current[i] = el
+ }}
+ className={cn ('relative z-10 flex h-full items-center px-5',
+ (i === openItemIdx) && 'font-bold')}>
+ {item.name}
+ ))}
+
-
+
- {menu.find (item => location.pathname.startsWith (item.base || item.to))?.subMenu
- .filter (item => item.visible ?? true)
- .map ((item, i) => 'component' in item ? item.component : (
-
- {item.name}
- ))}
+
+
+ ({ 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
+ ? {item.component}
+ : (
+
+ {item.name}
+ )))}
+
+
-
-
- {menu.map ((item, i) => (
-
- {
- if (i !== openItemIdx)
- {
- ev.preventDefault ()
- setOpenItemIdx (i)
- }
- }}>
- {item.name}
-
- {i === openItemIdx && (
- item.subMenu
- .filter (subItem => subItem.visible ?? true)
- .map ((subItem, j) => 'component' in subItem ? subItem.component : (
-
- {subItem.name}
- )))}
- ))}
-
-
-
+
+ {menuOpen && (
+
+
+ {menu.map ((item, i) => (
+
+ {
+ if (i !== openItemIdx)
+ {
+ ev.preventDefault ()
+ setOpenItemIdx (i)
+ }
+ }}>
+ {item.name}
+
+
+
+ {i === openItemIdx && (
+
+ {item.subMenu
+ .filter (subItem => subItem.visible ?? true)
+ .map ((subItem, j) => (
+ 'component' in subItem
+ ? (
+
+ {subItem.component}
+ )
+ : (
+
+ {subItem.name}
+ )))}
+ )}
+
+ ))}
+
+
+ )}
+
>)
-}
+}) satisfies FC
diff --git a/frontend/src/components/TopNavUser.tsx b/frontend/src/components/TopNavUser.tsx
index 765ad5f..18a68f3 100644
--- a/frontend/src/components/TopNavUser.tsx
+++ b/frontend/src/components/TopNavUser.tsx
@@ -3,13 +3,15 @@ import { Link } from 'react-router-dom'
import Separator from '@/components/MenuSeparator'
import { cn } from '@/lib/utils'
+import type { FC } from 'react'
+
import type { User } from '@/types'
type Props = { user: User | null,
sp?: boolean }
-export default ({ user, sp }: Props) => {
+export default (({ user, sp }: Props) => {
if (!(user))
return
@@ -21,10 +23,10 @@ export default ({ user, sp }: Props) => {
return (
<>
- {sp && }
+ {sp && }
{user.name || '名もなきニジラー'}
>)
-}
+}) satisfies FC
diff --git a/frontend/src/components/TwitterEmbed.tsx b/frontend/src/components/TwitterEmbed.tsx
new file mode 100644
index 0000000..dd640c8
--- /dev/null
+++ b/frontend/src/components/TwitterEmbed.tsx
@@ -0,0 +1,21 @@
+import type { FC } from 'react'
+
+type Props = {
+ userId: string
+ statusId: string }
+
+
+export default (({ userId, statusId }: Props) => {
+ const now = (new Date).toLocaleDateString ()
+
+ return (
+ )
+}) satisfies FC
diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx
index 30727c7..7e96488 100644
--- a/frontend/src/components/WikiBody.tsx
+++ b/frontend/src/components/WikiBody.tsx
@@ -1,14 +1,105 @@
+import axios from 'axios'
+import toCamel from 'camelcase-keys'
+import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { Link } from 'react-router-dom'
+import remarkGFM from 'remark-gfm'
+
+import SectionTitle from '@/components/common/SectionTitle'
+import SubsectionTitle from '@/components/common/SubsectionTitle'
+import { API_BASE_URL } from '@/config'
+
+import type { FC } from 'react'
+import type { Components } from 'react-markdown'
+
+import type { WikiPage } from '@/types'
type Props = { title: string
body?: string }
+const mdComponents = { h1: ({ children }) => {children},
+ h2: ({ children }) => {children},
+ ol: ({ children }) => {children}
,
+ ul: ({ children }) => ,
+ a: (({ href, children }) => (
+ ['/', '.'].some (e => href?.startsWith (e))
+ ? {children}
+ : (
+
+ {children}
+ ))) } as const satisfies Components
+
+
+export default (({ title, body }: Props) => {
+ const [pageNames, setPageNames] = useState ([])
+ const [realBody, setRealBody] = useState ('')
+
+ useEffect (() => {
+ if (!(body))
+ return
+
+ void (async () => {
+ try
+ {
+ const res = await axios.get (`${ API_BASE_URL }/wiki`)
+ const data = toCamel (res.data as any, { deep: true }) as WikiPage[]
+ setPageNames (data.map (page => page.title).sort ((a, b) => b.length - a.length))
+ }
+ catch
+ {
+ setPageNames ([])
+ }
+ }) ()
+ }, [])
+
+ useEffect (() => {
+ setRealBody ('')
+ }, [body])
+
+ useEffect (() => {
+ if (!(body))
+ return
+
+ const matchIndices = (target: string, keyword: string) => {
+ const indices: number[] = []
+ let pos = 0
+ let idx
+ while ((idx = target.indexOf (keyword, pos)) >= 0)
+ {
+ indices.push (idx)
+ pos = idx + keyword.length
+ }
+
+ return indices
+ }
+
+ const linkIndices = (text: string, names: string[]): [string, [number, number]][] => {
+ const result: [string, [number, number]][] = []
+
+ names.forEach (name => {
+ matchIndices (text, name).forEach (idx => {
+ const start = idx
+ const end = idx + name.length
+ const overlaps = result.some (([, [st, ed]]) => start < ed && end > st)
+ if (!(overlaps))
+ result.push ([name, [start, end]])
+ })
+ })
+
+ return result.sort (([, [a]], [, [b]]) => b - a)
+ }
+
+ setRealBody (
+ linkIndices (body, pageNames).reduce ((acc, [name, [start, end]]) => (
+ acc.slice (0, start)
+ + `[${ name }](/wiki/${ encodeURIComponent (name) })`
+ + acc.slice (end)), body))
+ }, [body, pageNames])
-export default ({ title, body }: Props) => (
- (['/', '.'].some (e => href?.startsWith (e))
- ? {children}
- : {children})) }}>
- {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
- )
+ return (
+
+ {realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
+ )
+}) satisfies FC
diff --git a/frontend/src/components/common/DateTimeField.tsx b/frontend/src/components/common/DateTimeField.tsx
new file mode 100644
index 0000000..5dbaae8
--- /dev/null
+++ b/frontend/src/components/common/DateTimeField.tsx
@@ -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) => void }
+
+
+export default (({ value, onChange, className, onBlur }: Props) => {
+ const [local, setLocal] = useState ('')
+
+ useEffect (() => {
+ setLocal (value ? toDateTimeLocalValue (new Date (value)) : '')
+ }, [value])
+
+ return (
+ {
+ const v = ev.target.value
+ setLocal (v)
+ onChange?.(v ? (new Date (v)).toISOString () : null)
+ }}
+ onBlur={onBlur}/>)
+}) satisfies FC
diff --git a/frontend/src/components/common/Form.tsx b/frontend/src/components/common/Form.tsx
index 73169d3..eb6ea67 100644
--- a/frontend/src/components/common/Form.tsx
+++ b/frontend/src/components/common/Form.tsx
@@ -1,9 +1,9 @@
-import React from 'react'
+import type { FC, ReactNode } from 'react'
-type Props = { children: React.ReactNode }
+type Props = { children: ReactNode }
-export default ({ children }: Props) => (
+export default (({ children }: Props) => (
{children}
-
)
+ )) satisfies FC
diff --git a/frontend/src/components/common/Label.tsx b/frontend/src/components/common/Label.tsx
index 35d3b20..e3de262 100644
--- a/frontend/src/components/common/Label.tsx
+++ b/frontend/src/components/common/Label.tsx
@@ -21,7 +21,7 @@ export default ({ children, checkBox }: Props) => {
)
diff --git a/frontend/src/components/common/Pagination.tsx b/frontend/src/components/common/Pagination.tsx
new file mode 100644
index 0000000..5fb527e
--- /dev/null
+++ b/frontend/src/components/common/Pagination.tsx
@@ -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 (
+ )
+}) satisfies FC
diff --git a/frontend/src/components/common/TextArea.tsx b/frontend/src/components/common/TextArea.tsx
index b90e3ac..64178da 100644
--- a/frontend/src/components/common/TextArea.tsx
+++ b/frontend/src/components/common/TextArea.tsx
@@ -1,10 +1,9 @@
-import React from 'react'
+import { forwardRef } from 'react'
-type Props = { value?: string
- onChange?: (event: React.ChangeEvent) => void }
+import type { TextareaHTMLAttributes } from 'react'
+type Props = TextareaHTMLAttributes
-export default ({ value, onChange }: Props) => (
- )
+
+export default forwardRef (({ ...props }, ref) => (
+ ))
diff --git a/frontend/src/components/users/InheritDialogue.tsx b/frontend/src/components/users/InheritDialogue.tsx
index 26141bf..49c1818 100644
--- a/frontend/src/components/users/InheritDialogue.tsx
+++ b/frontend/src/components/users/InheritDialogue.tsx
@@ -51,7 +51,7 @@ export default ({ visible, onVisibleChange, setUser }: Props) => {
setInputCode (ev.target.value)} />
+ onChange={ev => setInputCode (ev.target.value)}/>
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 052faac..263b75d 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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; }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index af68715..e823685 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -8,5 +8,5 @@ const helmetContext = { }
createRoot (document.getElementById ('root')!).render (
-
+
)
diff --git a/frontend/src/pages/Forbidden.tsx b/frontend/src/pages/Forbidden.tsx
index fea1604..54455c5 100644
--- a/frontend/src/pages/Forbidden.tsx
+++ b/frontend/src/pages/Forbidden.tsx
@@ -1,4 +1,4 @@
import ErrorScreen from '@/components/ErrorScreen'
-export default () =>
+export default () =>
diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx
index 8552471..21062b3 100644
--- a/frontend/src/pages/NotFound.tsx
+++ b/frontend/src/pages/NotFound.tsx
@@ -1,4 +1,4 @@
import ErrorScreen from '@/components/ErrorScreen'
-export default () =>
+export default () =>
diff --git a/frontend/src/pages/ServiceUnavailable.tsx b/frontend/src/pages/ServiceUnavailable.tsx
index 113e8f5..ac2c323 100644
--- a/frontend/src/pages/ServiceUnavailable.tsx
+++ b/frontend/src/pages/ServiceUnavailable.tsx
@@ -1,4 +1,4 @@
import ErrorScreen from '@/components/ErrorScreen'
-export default () =>
+export default () =>
diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx
index fe29eed..2955530 100644
--- a/frontend/src/pages/posts/PostDetailPage.tsx
+++ b/frontend/src/pages/posts/PostDetailPage.tsx
@@ -6,8 +6,8 @@ import { useParams } from 'react-router-dom'
import PostList from '@/components/PostList'
import TagDetailSidebar from '@/components/TagDetailSidebar'
-import NicoViewer from '@/components/NicoViewer'
import PostEditForm from '@/components/PostEditForm'
+import PostEmbed from '@/components/PostEmbed'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
@@ -17,12 +17,14 @@ import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
+import type { FC } from 'react'
+
import type { Post, User } from '@/types'
type Props = { user: User | null }
-export default ({ user }: Props) => {
+export default (({ user }: Props) => {
const { id } = useParams ()
const [post, setPost] = useState (null)
@@ -72,15 +74,11 @@ export default ({ user }: Props) => {
switch (status)
{
case 404:
- return
+ return
case 503:
- return
+ return
}
- const url = post ? new URL (post.url) : null
- const nicoFlg = url?.hostname.split ('.').slice (-2).join ('.') === 'nicovideo.jp'
- const match = nicoFlg ? url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/) : null
- const videoId = match?.[0] ?? ''
const viewedClass = (post?.viewed
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-500 hover:bg-gray-600')
@@ -89,22 +87,17 @@ export default ({ user }: Props) => {
{(post?.thumbnail || post?.thumbnailBase) && (
- )}
+ )}
{post && {`${ post.title || post.url } | ${ SITE_TITLE }`}}
-
+
{post
? (
<>
- {nicoFlg
- ? (
- )
- :
}
+
-
+
)
-}
+}) satisfies FC
diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx
new file mode 100644
index 0000000..e194dbc
--- /dev/null
+++ b/frontend/src/pages/posts/PostHistoryPage.tsx
@@ -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 ([])
+ const [totalPages, setTotalPages] = useState (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 (
+
+
+ {`耕作履歴 | ${ SITE_TITLE }`}
+
+
+
+ 耕作履歴
+ {id && <>: 投稿 {#{id}}>}
+
+
+
+
+
+ | 投稿 |
+ 変更 |
+ 日時 |
+
+
+
+ {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 (
+
+ {withPost && (
+
+
+
+
+ | )}
+
+
+ {`を${ change.changeType === 'add' ? '追加' : '削除' }`}
+ |
+
+ {change.user ? (
+
+ {change.user.name}
+ ) : 'bot 操作'}
+
+ {change.timestamp}
+ |
+
)
+ })}
+
+
+
+
+ )
+}) satisfies FC
diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx
index 95003b5..50cc04f 100644
--- a/frontend/src/pages/posts/PostListPage.tsx
+++ b/frontend/src/pages/posts/PostListPage.tsx
@@ -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 ([])
+ const [totalPages, setTotalPages] = useState (0)
const [wikiPage, setWikiPage] = useState (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)
@@ -111,27 +120,32 @@ export default () => {
-
+
- {posts.length
+ {posts.length > 0
? (
- {
- const statesToSave = {
- posts, cursor,
- scroll: containerRef.current?.scrollTop ?? 0 }
- sessionStorage.setItem (`posts:${ tagsQuery }`,
- JSON.stringify (statesToSave))
- }} />)
+ <>
+ {
+ // TODO: 無限ロード用なので復活時に戻す.
+ // const statesToSave = {
+ // posts, cursor,
+ // scroll: containerRef.current?.scrollTop ?? 0 }
+ // sessionStorage.setItem (`posts:${ tagsQuery }`,
+ // JSON.stringify (statesToSave))
+ }}/>
+
+ >)
: !(loading) && '広場には何もありませんよ.'}
{loading && 'Loading...'}
-
+ {/* TODO: 無限ローディング復活までコメント・アウト */}
+ {/* */}
{tags.length === 1 && (
-
+
Wiki を見る
diff --git a/frontend/src/pages/posts/PostNewPage.tsx b/frontend/src/pages/posts/PostNewPage.tsx
index be8d216..f7ce25c 100644
--- a/frontend/src/pages/posts/PostNewPage.tsx
+++ b/frontend/src/pages/posts/PostNewPage.tsx
@@ -3,36 +3,41 @@ import { useEffect, useState, useRef } from 'react'
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'
-import TextArea from '@/components/common/TextArea'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { API_BASE_URL, SITE_TITLE } from '@/config'
import Forbidden from '@/pages/Forbidden'
+import type { FC } from 'react'
+
import type { User } from '@/types'
type Props = { user: User | null }
-export default ({ user }: Props) => {
+export default (({ user }: Props) => {
if (!(['admin', 'member'].some (r => user?.role === r)))
- return
+ return
const navigate = useNavigate ()
+ const [originalCreatedBefore, setOriginalCreatedBefore] = useState
(null)
+ const [originalCreatedFrom, setOriginalCreatedFrom] = useState (null)
+ const [tags, setTags] = useState ('')
+ const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
+ const [thumbnailFile, setThumbnailFile] = useState (null)
+ const [thumbnailLoading, setThumbnailLoading] = useState (false)
+ const [thumbnailPreview, setThumbnailPreview] = useState ('')
const [title, setTitle] = useState ('')
const [titleAutoFlg, setTitleAutoFlg] = useState (true)
const [titleLoading, setTitleLoading] = useState (false)
const [url, setURL] = useState ('')
- const [thumbnailFile, setThumbnailFile] = useState (null)
- const [thumbnailPreview, setThumbnailPreview] = useState ('')
- const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
- const [thumbnailLoading, setThumbnailLoading] = useState (false)
- const [tags, setTags] = useState ('')
const previousURLRef = useRef ('')
@@ -43,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
{
@@ -120,18 +129,18 @@ export default ({ user }: Props) => {
{/* URL */}
- setURL (e.target.value)}
className="w-full border p-2 rounded"
- onBlur={handleURLBlur} />
+ onBlur={handleURLBlur}/>
{/* タイトル */}
{/* サムネール */}
{/* タグ */}
- {/* TextArea で自由形式にする */}
-
-
-
+
+
+ {/* オリジナルの作成日時 */}
+
{/* 送信 */}
)
-}
+}) satisfies FC
diff --git a/frontend/src/pages/tags/NicoTagListPage.tsx b/frontend/src/pages/tags/NicoTagListPage.tsx
index 44f5054..c91a7c9 100644
--- a/frontend/src/pages/tags/NicoTagListPage.tsx
+++ b/frontend/src/pages/tags/NicoTagListPage.tsx
@@ -114,19 +114,19 @@ export default ({ user }: Props) => {
{nicoTags.map ((tag, i) => (
|
-
+
|
{editing[tag.id]
? (
|
{memberFlg && (
diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx
index 5883113..d57682e 100644
--- a/frontend/src/pages/users/SettingPage.tsx
+++ b/frontend/src/pages/users/SettingPage.tsx
@@ -55,7 +55,7 @@ export default ({ user, setUser }: Props) => {
return (
-
+
設定 | {SITE_TITLE}
@@ -71,7 +71,7 @@ export default ({ user, setUser }: Props) => {
className="w-full border rounded p-2"
value={name}
placeholder="名もなきニジラー"
- onChange={ev => setName (ev.target.value)} />
+ onChange={ev => setName (ev.target.value)}/>
{(user && !(user.name)) && (
名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!!
@@ -104,10 +104,10 @@ export default ({ user, setUser }: Props) => {
+ setUser={setUser}/>
+ setUser={setUser}/>
)
}
diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx
index be69f59..b5ebd79 100644
--- a/frontend/src/pages/wiki/WikiDetailPage.tsx
+++ b/frontend/src/pages/wiki/WikiDetailPage.tsx
@@ -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)
}
@@ -60,6 +69,7 @@ export default () => {
}
}) ()
+ setPosts ([])
void (async () => {
try
{
@@ -73,7 +83,7 @@ export default () => {
}
catch
{
- setPosts ([])
+ ;
}
}) ()
@@ -97,6 +107,7 @@ export default () => {
{`${ title } Wiki | ${ SITE_TITLE }`}
+ {!(wikiPage?.body) && }
{(wikiPage && version) && (
@@ -118,18 +129,18 @@ export default () => {
+ {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
{wikiPage === undefined
? 'Loading...'
- : }
+ : }
{(!(version) && posts.length > 0) && (
-
+
)}
)
diff --git a/frontend/src/pages/wiki/WikiDiffPage.tsx b/frontend/src/pages/wiki/WikiDiffPage.tsx
index ef8bdcb..85a62f0 100644
--- a/frontend/src/pages/wiki/WikiDiffPage.tsx
+++ b/frontend/src/pages/wiki/WikiDiffPage.tsx
@@ -42,7 +42,7 @@ export default () => {
diff.diff.map (d => (
- {d.content == '\n' ?
: d.content}
+ {d.content == '\n' ?
: d.content}
)))
: 'Loading...'}
diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx
index 064fa1b..62a2f06 100644
--- a/frontend/src/pages/wiki/WikiEditPage.tsx
+++ b/frontend/src/pages/wiki/WikiEditPage.tsx
@@ -21,7 +21,7 @@ type Props = { user: User | null }
export default ({ user }: Props) => {
if (!(['admin', 'member'].some (r => user?.role === r)))
- return
+ return
const { id } = useParams ()
@@ -73,7 +73,7 @@ export default ({ user }: Props) => {
setTitle (e.target.value)}
- className="w-full border p-2 rounded" />
+ className="w-full border p-2 rounded"/>
{/* 本文 */}
@@ -82,7 +82,7 @@ export default ({ user }: Props) => {
mdParser.render (text)}
- onChange={({ text }) => setBody (text)} />
+ onChange={({ text }) => setBody (text)}/>
{/* 送信 */}
diff --git a/frontend/src/pages/wiki/WikiHistoryPage.tsx b/frontend/src/pages/wiki/WikiHistoryPage.tsx
index c90ad20..ba1d71b 100644
--- a/frontend/src/pages/wiki/WikiHistoryPage.tsx
+++ b/frontend/src/pages/wiki/WikiHistoryPage.tsx
@@ -27,54 +27,54 @@ export default () => {
return (
-
- {`Wiki 変更履歴 | ${ SITE_TITLE }`}
-
-
-
-
- |
- タイトル |
- 変更 |
- 日時 |
-
-
-
- {changes.map (change => (
-
- |
- {change.changeType === 'update' && (
-
- 差分
- )}
- |
-
-
- {change.wikiPage.title}
-
- |
-
- {(() => {
- switch (change.changeType)
- {
- case 'create':
- return '新規'
- case 'update':
- return '更新'
- case 'delete':
- return '削除'
- }
- }) ()}
- |
-
-
- {change.user.name}
-
-
- {change.timestamp}
- |
-
))}
-
-
+
+ {`Wiki 変更履歴 | ${ SITE_TITLE }`}
+
+
+
+
+ |
+ タイトル |
+ 変更 |
+ 日時 |
+
+
+
+ {changes.map (change => (
+
+ |
+ {change.changeType === 'update' && (
+
+ 差分
+ )}
+ |
+
+
+ {change.wikiPage.title}
+
+ |
+
+ {(() => {
+ switch (change.changeType)
+ {
+ case 'create':
+ return '新規'
+ case 'update':
+ return '更新'
+ case 'delete':
+ return '削除'
+ }
+ }) ()}
+ |
+
+
+ {change.user.name}
+
+
+ {change.timestamp}
+ |
+
))}
+
+
)
}
diff --git a/frontend/src/pages/wiki/WikiNewPage.tsx b/frontend/src/pages/wiki/WikiNewPage.tsx
index 5402162..ac442e7 100644
--- a/frontend/src/pages/wiki/WikiNewPage.tsx
+++ b/frontend/src/pages/wiki/WikiNewPage.tsx
@@ -21,7 +21,7 @@ type Props = { user: User | null }
export default ({ user }: Props) => {
if (!(['admin', 'member'].some (r => user?.role === r)))
- return
+ return
const location = useLocation ()
const navigate = useNavigate ()
@@ -67,7 +67,7 @@ export default ({ user }: Props) => {
setTitle (e.target.value)}
- className="w-full border p-2 rounded" />
+ className="w-full border p-2 rounded"/>
{/* 本文 */}
@@ -76,7 +76,7 @@ export default ({ user }: Props) => {
mdParser.render (text)}
- onChange={({ text }) => setBody (text)} />
+ onChange={({ text }) => setBody (text)}/>
{/* 送信 */}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
deleted file mode 100644
index 5f01ba1..0000000
--- a/frontend/src/router.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-// import { Route,
-// createBrowserRouter,
-// createRoutesFromElements } from 'react-router-dom'
-//
-// import App from '@/App'
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 664bc8d..f78c85b 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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[] }
-
-export type SubMenuItem = {
- component: React.ReactNode
- visible: boolean
- } | {
- name: string
- to: string
- visible?: boolean }
+ 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 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
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index a56efa4..fddc378 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -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