| @@ -63,3 +63,5 @@ gem 'diff-lcs' | |||||
| gem 'dotenv-rails' | gem 'dotenv-rails' | ||||
| gem 'whenever', require: false | gem 'whenever', require: false | ||||
| gem 'discard' | |||||
| @@ -90,6 +90,8 @@ GEM | |||||
| crass (1.0.6) | crass (1.0.6) | ||||
| date (3.4.1) | date (3.4.1) | ||||
| diff-lcs (1.6.2) | diff-lcs (1.6.2) | ||||
| discard (1.4.0) | |||||
| activerecord (>= 4.2, < 9.0) | |||||
| dotenv (3.1.8) | dotenv (3.1.8) | ||||
| dotenv-rails (3.1.8) | dotenv-rails (3.1.8) | ||||
| dotenv (= 3.1.8) | dotenv (= 3.1.8) | ||||
| @@ -420,6 +422,7 @@ DEPENDENCIES | |||||
| bootsnap | bootsnap | ||||
| brakeman | brakeman | ||||
| diff-lcs | diff-lcs | ||||
| discard | |||||
| dotenv-rails | dotenv-rails | ||||
| gollum | gollum | ||||
| image_processing (~> 1.14) | image_processing (~> 1.14) | ||||
| @@ -1,34 +1,50 @@ | |||||
| require 'open-uri' | |||||
| require 'nokogiri' | |||||
| class PostsController < ApplicationController | class PostsController < ApplicationController | ||||
| Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) | |||||
| # GET /posts | # GET /posts | ||||
| def index | def index | ||||
| limit = params[:limit].presence&.to_i | |||||
| page = (params[:page].presence || 1).to_i | |||||
| limit = (params[:limit].presence || 20).to_i | |||||
| cursor = params[:cursor].presence | 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 | 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) | posts = posts.first(limit) | ||||
| end | end | ||||
| render json: { posts: posts.map { |post| | 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'] = | json['thumbnail'] = | ||||
| if post.thumbnail.attached? | if post.thumbnail.attached? | ||||
| rails_storage_proxy_url(post.thumbnail, only_path: false) | rails_storage_proxy_url(post.thumbnail, only_path: false) | ||||
| else | else | ||||
| nil | nil | ||||
| end | end | ||||
| } | |||||
| }, next_cursor: } | |||||
| end | |||||
| }, count: filtered_posts.count(:id), next_cursor: } | |||||
| end | end | ||||
| def random | def random | ||||
| @@ -39,7 +55,7 @@ class PostsController < ApplicationController | |||||
| render json: (post | render json: (post | ||||
| .as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) | .as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) | ||||
| .merge(viewed: viewed)) | |||||
| .merge(viewed:)) | |||||
| end | end | ||||
| # GET /posts/1 | # GET /posts/1 | ||||
| @@ -49,9 +65,12 @@ class PostsController < ApplicationController | |||||
| viewed = current_user&.viewed?(post) || false | 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 | end | ||||
| # POST /posts | # POST /posts | ||||
| @@ -60,18 +79,23 @@ class PostsController < ApplicationController | |||||
| return head :forbidden unless current_user.member? | return head :forbidden unless current_user.member? | ||||
| # TODO: URL が正規のものがチェック,不正ならエラー | # TODO: URL が正規のものがチェック,不正ならエラー | ||||
| # TODO: title、URL は必須にする. | |||||
| # TODO: URL は必須にする(タイトルは省略可). | |||||
| # TODO: サイトに応じて thumbnail_base 設定 | # TODO: サイトに応じて thumbnail_base 設定 | ||||
| title = params[:title] | title = params[:title] | ||||
| url = params[:url] | url = params[:url] | ||||
| thumbnail = params[:thumbnail] | thumbnail = params[:thumbnail] | ||||
| tag_names = params[:tags].to_s.split(' ') | 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) | post.thumbnail.attach(thumbnail) | ||||
| if post.save | if post.save | ||||
| post.resized_thumbnail! | post.resized_thumbnail! | ||||
| post.tags = Tag.normalise_tags(tag_names) | |||||
| tags = Tag.normalise_tags(tag_names) | |||||
| tags = Tag.expand_parent_tags(tags) | |||||
| sync_post_tags!(post, tags) | |||||
| render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), | render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), | ||||
| status: :created | status: :created | ||||
| else | else | ||||
| @@ -100,12 +124,18 @@ class PostsController < ApplicationController | |||||
| title = params[:title] | title = params[:title] | ||||
| tag_names = params[:tags].to_s.split(' ') | 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) | 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 | else | ||||
| render json: post.errors, status: :unprocessable_entity | render json: post.errors, status: :unprocessable_entity | ||||
| end | end | ||||
| @@ -115,12 +145,54 @@ class PostsController < ApplicationController | |||||
| def destroy | def destroy | ||||
| end | end | ||||
| def changes | |||||
| id = params[:id] | |||||
| page = (params[:page].presence || 1).to_i | |||||
| limit = (params[:limit].presence || 20).to_i | |||||
| page = 1 if page < 1 | |||||
| limit = 1 if limit < 1 | |||||
| offset = (page - 1) * limit | |||||
| pts = PostTag.with_discarded | |||||
| pts = pts.where(post_id: id) if id.present? | |||||
| pts = pts.includes(:post, :tag, :created_user, :deleted_user) | |||||
| events = [] | |||||
| pts.each do |pt| | |||||
| events << Event.new( | |||||
| post: pt.post, | |||||
| tag: pt.tag, | |||||
| user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name }, | |||||
| change_type: 'add', | |||||
| timestamp: pt.created_at) | |||||
| if pt.discarded_at | |||||
| events << Event.new( | |||||
| post: pt.post, | |||||
| tag: pt.tag, | |||||
| user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name }, | |||||
| change_type: 'remove', | |||||
| timestamp: pt.discarded_at) | |||||
| end | |||||
| end | |||||
| events.sort_by!(&:timestamp) | |||||
| events.reverse! | |||||
| render json: { changes: events.slice(offset, limit).as_json, count: events.size } | |||||
| end | |||||
| private | private | ||||
| def filtered_posts | def filtered_posts | ||||
| tag_names = params[:tags]&.split(' ') | tag_names = params[:tags]&.split(' ') | ||||
| match_type = params[:match] | match_type = params[:match] | ||||
| tag_names.present? ? filter_posts_by_tags(tag_names, match_type) : Post.all | |||||
| if tag_names.present? | |||||
| filter_posts_by_tags(tag_names, match_type) | |||||
| else | |||||
| Post.all | |||||
| end | |||||
| end | end | ||||
| def filter_posts_by_tags tag_names, match_type | def filter_posts_by_tags tag_names, match_type | ||||
| @@ -134,4 +206,70 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| posts.distinct | posts.distinct | ||||
| end | end | ||||
| def sync_post_tags! post, desired_tags | |||||
| desired_tags.each do |t| | |||||
| t.save! if t.new_record? | |||||
| end | |||||
| desired_ids = desired_tags.map(&:id).to_set | |||||
| current_ids = post.tags.pluck(:id).to_set | |||||
| to_add = desired_ids - current_ids | |||||
| to_remove = current_ids - desired_ids | |||||
| Tag.where(id: to_add).find_each do |tag| | |||||
| begin | |||||
| PostTag.create!(post:, tag:, created_user: current_user) | |||||
| rescue ActiveRecord::RecordNotUnique | |||||
| ; | |||||
| end | |||||
| end | |||||
| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| | |||||
| pt.discard_by!(current_user) | |||||
| end | |||||
| end | |||||
| def build_tag_tree_for tags | |||||
| 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 | end | ||||
| @@ -6,12 +6,15 @@ class UsersController < ApplicationController | |||||
| end | end | ||||
| def verify | 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]) | 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 | end | ||||
| def renew | def renew | ||||
| @@ -13,6 +13,22 @@ class WikiPagesController < ApplicationController | |||||
| render_wiki_page_or_404 WikiPage.find_by(title: params[:title]) | render_wiki_page_or_404 WikiPage.find_by(title: params[:title]) | ||||
| end | 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 | def diff | ||||
| id = params[:id] | id = params[:id] | ||||
| from = params[:from] | from = params[:from] | ||||
| @@ -6,6 +6,7 @@ class NicoTagRelation < ApplicationRecord | |||||
| validates :tag_id, presence: true | validates :tag_id, presence: true | ||||
| validate :nico_tag_must_be_nico | validate :nico_tag_must_be_nico | ||||
| validate :tag_mustnt_be_nico | |||||
| private | private | ||||
| @@ -1,11 +1,13 @@ | |||||
| require 'mini_magick' | |||||
| class Post < ApplicationRecord | class Post < ApplicationRecord | ||||
| require 'mini_magick' | |||||
| belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id' | belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id' | ||||
| belongs_to :uploaded_user, class_name: 'User', optional: true | belongs_to :uploaded_user, class_name: 'User', optional: true | ||||
| has_many :post_tags, dependent: :destroy | |||||
| has_many :tags, through: :post_tags | |||||
| has_many :post_tags, dependent: :destroy, inverse_of: :post | |||||
| has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post | |||||
| has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' | |||||
| has_many :tags, through: :active_post_tags | |||||
| has_many :user_post_views, dependent: :destroy | has_many :user_post_views, dependent: :destroy | ||||
| has_many :post_similarities_as_post, | has_many :post_similarities_as_post, | ||||
| class_name: 'PostSimilarity', | class_name: 'PostSimilarity', | ||||
| @@ -15,6 +17,8 @@ class Post < ApplicationRecord | |||||
| foreign_key: :target_post_id | foreign_key: :target_post_id | ||||
| has_one_attached :thumbnail | has_one_attached :thumbnail | ||||
| validate :validate_original_created_range | |||||
| def as_json options = { } | def as_json options = { } | ||||
| super(options).merge({ thumbnail: thumbnail.attached? ? | super(options).merge({ thumbnail: thumbnail.attached? ? | ||||
| Rails.application.routes.url_helpers.rails_blob_url( | Rails.application.routes.url_helpers.rails_blob_url( | ||||
| @@ -49,4 +53,20 @@ class Post < ApplicationRecord | |||||
| filename: 'resized_thumbnail.jpg', | filename: 'resized_thumbnail.jpg', | ||||
| content_type: 'image/jpeg') | content_type: 'image/jpeg') | ||||
| end | 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 | end | ||||
| @@ -1,7 +1,25 @@ | |||||
| class PostTag < ApplicationRecord | class PostTag < ApplicationRecord | ||||
| include Discard::Model | |||||
| belongs_to :post | belongs_to :post | ||||
| belongs_to :tag, counter_cache: :post_count | belongs_to :tag, counter_cache: :post_count | ||||
| belongs_to :created_user, class_name: 'User', optional: true | |||||
| belongs_to :deleted_user, class_name: 'User', optional: true | |||||
| validates :post_id, presence: true | validates :post_id, presence: true | ||||
| validates :tag_id, presence: true | validates :tag_id, presence: true | ||||
| validates :post_id, uniqueness: { | |||||
| scope: :tag_id, | |||||
| conditions: -> { where(discarded_at: nil) } } | |||||
| def discard_by! deleted_user | |||||
| return self if discarded? | |||||
| transaction do | |||||
| update!(discarded_at: Time.current, deleted_user:) | |||||
| Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)') | |||||
| end | |||||
| self | |||||
| end | |||||
| end | end | ||||
| @@ -1,6 +1,8 @@ | |||||
| class Tag < ApplicationRecord | class Tag < ApplicationRecord | ||||
| has_many :post_tags, dependent: :destroy | |||||
| has_many :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 :tag_aliases, dependent: :destroy | ||||
| has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy | has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy | ||||
| @@ -11,6 +13,14 @@ class Tag < ApplicationRecord | |||||
| dependent: :destroy | dependent: :destroy | ||||
| has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag | 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', | enum :category, { deerjikist: 'deerjikist', | ||||
| meme: 'meme', | meme: 'meme', | ||||
| character: 'character', | character: 'character', | ||||
| @@ -35,19 +45,19 @@ class Tag < ApplicationRecord | |||||
| 'meta:' => 'meta' }.freeze | 'meta:' => 'meta' }.freeze | ||||
| def self.tagme | def self.tagme | ||||
| @tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag| | |||||
| @tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag| | |||||
| tag.category = 'meta' | tag.category = 'meta' | ||||
| end | end | ||||
| end | end | ||||
| def self.bot | def self.bot | ||||
| @bot ||= Tag.find_or_initialize_by(name: 'bot操作') do |tag| | |||||
| @bot ||= Tag.find_or_create_by!(name: 'bot操作') do |tag| | |||||
| tag.category = 'meta' | tag.category = 'meta' | ||||
| end | end | ||||
| end | end | ||||
| def self.no_deerjikist | def self.no_deerjikist | ||||
| @no_deerjikist ||= Tag.find_or_initialize_by(name: 'ニジラー情報なし') do |tag| | |||||
| @no_deerjikist ||= Tag.find_or_initialize_by(name: 'ニジラー情報不詳') do |tag| | |||||
| tag.category = 'meta' | tag.category = 'meta' | ||||
| end | end | ||||
| end | end | ||||
| @@ -63,11 +73,34 @@ class Tag < ApplicationRecord | |||||
| end | end | ||||
| end | end | ||||
| end | end | ||||
| tags << Tag.tagme if with_tagme && tags.size < 20 && tags.none?(Tag.tagme) | |||||
| tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) | |||||
| tags << Tag.no_deerjikist if tags.all? { |t| t.category != 'deerjika' } | tags << Tag.no_deerjikist if tags.all? { |t| t.category != 'deerjika' } | ||||
| tags.uniq | tags.uniq | ||||
| end | 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 | private | ||||
| def nico_tag_name_must_start_with_nico | def nico_tag_name_must_start_with_nico | ||||
| @@ -0,0 +1,17 @@ | |||||
| class TagImplication < ApplicationRecord | |||||
| belongs_to :tag, class_name: 'Tag' | |||||
| belongs_to :parent_tag, class_name: 'Tag' | |||||
| validates :tag_id, presence: true, uniqueness: { scope: :parent_tag_id } | |||||
| validates :parent_tag_id, presence: true | |||||
| validate :parent_tag_mustnt_be_itself | |||||
| private | |||||
| def parent_tag_mustnt_be_itself | |||||
| if parent_tag == tag | |||||
| errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.' | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -8,7 +8,6 @@ class User < ApplicationRecord | |||||
| has_many :posts | has_many :posts | ||||
| has_many :settings | has_many :settings | ||||
| has_many :ip_addresses | |||||
| has_many :user_ips, dependent: :destroy | has_many :user_ips, dependent: :destroy | ||||
| has_many :ip_addresses, through: :user_ips | has_many :ip_addresses, through: :user_ips | ||||
| has_many :user_post_views, dependent: :destroy | has_many :user_post_views, dependent: :destroy | ||||
| @@ -1,45 +1,60 @@ | |||||
| Rails.application.routes.draw do | 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 | resources :users, only: [:create, :update] do | ||||
| collection do | collection do | ||||
| post :verify | post :verify | ||||
| get :me | get :me | ||||
| post 'code/renew', action: :renew | |||||
| end | end | ||||
| 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 | end | ||||
| @@ -0,0 +1,6 @@ | |||||
| class AddOriginalCreatedAtToPosts < ActiveRecord::Migration[8.0] | |||||
| def change | |||||
| add_column :posts, :original_created_from, :datetime, after: :created_at | |||||
| add_column :posts, :original_created_before, :datetime, after: :original_created_from | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,9 @@ | |||||
| class CreateTagImplications < ActiveRecord::Migration[8.0] | |||||
| def change | |||||
| create_table :tag_implications do |t| | |||||
| t.references :tag, null: false, foreign_key: { to_table: :tags } | |||||
| t.references :parent_tag, null: false, foreign_key: { to_table: :tags } | |||||
| t.timestamps | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,36 @@ | |||||
| class AddDiscardToPostTags < ActiveRecord::Migration[8.0] | |||||
| def up | |||||
| execute <<~SQL | |||||
| DELETE | |||||
| pt1 | |||||
| FROM | |||||
| post_tags pt1 | |||||
| INNER JOIN | |||||
| post_tags pt2 | |||||
| ON | |||||
| pt1.post_id = pt2.post_id | |||||
| AND pt1.tag_id = pt2.tag_id | |||||
| AND pt1.id > pt2.id | |||||
| ; | |||||
| SQL | |||||
| add_column :post_tags, :discarded_at, :datetime | |||||
| add_index :post_tags, :discarded_at | |||||
| add_column :post_tags, :is_active, :boolean, | |||||
| as: 'discarded_at IS NULL', stored: true | |||||
| add_column :post_tags, :active_unique_key, :string, | |||||
| as: "CASE WHEN discarded_at IS NULL THEN CONCAT(post_id, ':', tag_id) ELSE NULL END", | |||||
| stored: true | |||||
| add_index :post_tags, :active_unique_key, unique: true, name: 'idx_post_tags_active_unique' | |||||
| add_index :post_tags, [:post_id, :discarded_at] | |||||
| add_index :post_tags, [:tag_id, :discarded_at] | |||||
| end | |||||
| def down | |||||
| raise ActiveRecord::IrreversibleMigration, '戻せません.' | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,5 @@ | |||||
| class RenameIpAdressColumnToIpAddresses < ActiveRecord::Migration[8.0] | |||||
| def change | |||||
| rename_column :ip_addresses, :ip_adress, :ip_address | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,27 @@ | |||||
| class AddUniqueIndexToTagImplications < ActiveRecord::Migration[8.0] | |||||
| def up | |||||
| execute <<~SQL | |||||
| DELETE | |||||
| ti1 | |||||
| FROM | |||||
| tag_implications ti1 | |||||
| INNER JOIN | |||||
| tag_implications ti2 | |||||
| ON | |||||
| ti1.tag_id = ti2.tag_id | |||||
| AND ti1.parent_tag_id = ti2.parent_tag_id | |||||
| AND ti1.id > ti2.id | |||||
| ; | |||||
| SQL | |||||
| add_index :tag_implications, [:tag_id, :parent_tag_id], | |||||
| unique: true, | |||||
| name: 'index_tag_implications_on_tag_id_and_parent_tag_id' | |||||
| end | |||||
| def down | |||||
| # NOTE: 重複削除は復元されなぃ. | |||||
| remove_index :tag_implications, | |||||
| name: 'index_tag_implications_on_tag_id_and_parent_tag_id' | |||||
| end | |||||
| end | |||||
| @@ -10,7 +10,7 @@ | |||||
| # | # | ||||
| # It's strongly recommended that you check this file into your version control system. | # 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| | create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "name", null: false | t.string "name", null: false | ||||
| t.string "record_type", null: false | t.string "record_type", null: false | ||||
| @@ -40,7 +40,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do | |||||
| end | end | ||||
| create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | 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.boolean "banned", default: false, null: false | ||||
| t.datetime "created_at", null: false | t.datetime "created_at", null: false | ||||
| t.datetime "updated_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.bigint "deleted_user_id" | ||||
| t.datetime "created_at", null: false | t.datetime "created_at", null: false | ||||
| t.datetime "updated_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 ["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 ["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 ["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" | t.index ["tag_id"], name: "index_post_tags_on_tag_id" | ||||
| end | end | ||||
| @@ -83,6 +90,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do | |||||
| t.bigint "parent_id" | t.bigint "parent_id" | ||||
| t.bigint "uploaded_user_id" | t.bigint "uploaded_user_id" | ||||
| t.datetime "created_at", null: false | t.datetime "created_at", null: false | ||||
| t.datetime "original_created_from" | |||||
| t.datetime "original_created_before" | |||||
| t.datetime "updated_at", null: false | t.datetime "updated_at", null: false | ||||
| t.index ["parent_id"], name: "index_posts_on_parent_id" | t.index ["parent_id"], name: "index_posts_on_parent_id" | ||||
| t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_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" | t.index ["tag_id"], name: "index_tag_aliases_on_tag_id" | ||||
| end | 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| | create_table "tag_similarities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.bigint "tag_id", null: false | t.bigint "tag_id", null: false | ||||
| t.bigint "target_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 "posts", "users", column: "uploaded_user_id" | ||||
| add_foreign_key "settings", "users" | add_foreign_key "settings", "users" | ||||
| add_foreign_key "tag_aliases", "tags" | 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" | ||||
| add_foreign_key "tag_similarities", "tags", column: "target_tag_id" | add_foreign_key "tag_similarities", "tags", column: "target_tag_id" | ||||
| add_foreign_key "user_ips", "ip_addresses" | add_foreign_key "user_ips", "ip_addresses" | ||||
| @@ -1,12 +0,0 @@ | |||||
| namespace :nico do | |||||
| desc 'ニコタグ連携' | |||||
| task link: :environment do | |||||
| Post.find_each do |post| | |||||
| tags = post.tags.where(category: 'nico') | |||||
| tags.each do |tag| | |||||
| post.tags.concat(tag.linked_tags) if tag.linked_tags.present? | |||||
| end | |||||
| post.tags = post.tags.to_a.uniq | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -1,16 +1,38 @@ | |||||
| namespace :nico do | namespace :nico do | ||||
| desc 'ニコニコ DB 同期' | desc 'ニコニコ DB 同期' | ||||
| task sync: :environment do | task sync: :environment do | ||||
| require 'json' | |||||
| require 'nokogiri' | |||||
| require 'open3' | require 'open3' | ||||
| require 'open-uri' | require 'open-uri' | ||||
| require 'nokogiri' | |||||
| require 'set' | |||||
| fetch_thumbnail = -> url { | |||||
| fetch_thumbnail = -> url do | |||||
| html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read | html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read | ||||
| doc = Nokogiri::HTML(html) | doc = Nokogiri::HTML(html) | ||||
| doc.at('meta[name="thumbnail"]')&.[]('content').presence | doc.at('meta[name="thumbnail"]')&.[]('content').presence | ||||
| } | |||||
| end | |||||
| def sync_post_tags! post, desired_tag_ids | |||||
| desired_ids = desired_tag_ids.compact.to_set | |||||
| current_ids = post.tags.pluck(:id).to_set | |||||
| to_add = desired_ids - current_ids | |||||
| to_remove = current_ids - desired_ids | |||||
| Tag.where(id: to_add.to_a).find_each do |tag| | |||||
| begin | |||||
| PostTag.create!(post:, tag:) | |||||
| rescue ActiveRecord::RecordNotUnique | |||||
| ; | |||||
| end | |||||
| end | |||||
| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| | |||||
| pt.discard_by!(nil) | |||||
| end | |||||
| end | |||||
| mysql_user = ENV['MYSQL_USER'] | mysql_user = ENV['MYSQL_USER'] | ||||
| mysql_pass = ENV['MYSQL_PASS'] | mysql_pass = ENV['MYSQL_PASS'] | ||||
| @@ -19,44 +41,60 @@ namespace :nico do | |||||
| { 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, | { 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, | ||||
| 'python3', "#{ nizika_nico_path }/get_videos.py") | 'python3', "#{ nizika_nico_path }/get_videos.py") | ||||
| if status.success? | |||||
| 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 | end | ||||
| post.save! | |||||
| post.resized_thumbnail! | |||||
| sync_post_tags!(post, [Tag.tagme.id]) | |||||
| end | |||||
| current_tags = post.tags.where(category: 'nico').pluck(:name).sort | |||||
| new_tags = datum['tags'].map { |tag| "nico:#{ tag }" }.sort | |||||
| if current_tags != new_tags | |||||
| post.tags.destroy(post.tags.where(name: current_tags)) | |||||
| tags_to_add = [] | |||||
| new_tags.each do |name| | |||||
| tag = Tag.find_or_initialize_by(name:) do |t| | |||||
| t.category = 'nico' | |||||
| end | |||||
| tags_to_add.concat([tag] + tag.linked_tags) | |||||
| end | |||||
| tags_to_add << Tag.tagme if post.tags.size < 20 | |||||
| tags_to_add << Tag.bot | |||||
| tags_to_add << Tag.no_deerjikist if post.tags.all? { |t| t.category != 'deerjikist' } | |||||
| post.tags = (post.tags + tags_to_add).uniq | |||||
| kept_tags = post.tags.reload | |||||
| kept_non_nico_ids = kept_tags.where.not(category: 'nico').pluck(:id).to_set | |||||
| desired_nico_ids = [] | |||||
| desired_non_nico_ids = [] | |||||
| datum['tags'].each do |raw| | |||||
| name = "nico:#{ raw }" | |||||
| tag = Tag.find_or_initialize_by(name:) do |t| | |||||
| t.category = 'nico' | |||||
| end | |||||
| tag.save! if tag.new_record? | |||||
| desired_nico_ids << tag.id | |||||
| unless tag.in?(kept_tags) | |||||
| desired_non_nico_ids.concat(tag.linked_tags.pluck(:id)) | |||||
| desired_nico_ids.concat(tag.linked_tags.pluck(:id)) | |||||
| end | end | ||||
| end | end | ||||
| desired_nico_ids.uniq! | |||||
| desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids | |||||
| desired_non_nico_ids.concat(kept_non_nico_ids.to_a) | |||||
| desired_non_nico_ids.uniq! | |||||
| if kept_non_nico_ids.to_set != desired_non_nico_ids.to_set | |||||
| desired_all_ids << Tag.bot.id | |||||
| end | |||||
| unless Tag.where(id: desired_all_ids).where(category: 'deerjikist').exists? | |||||
| desired_all_ids << Tag.no_deerjikist.id | |||||
| end | |||||
| desired_all_ids.uniq! | |||||
| sync_post_tags!(post, desired_all_ids) | |||||
| end | end | ||||
| end | end | ||||
| end | end | ||||
| @@ -9,6 +9,7 @@ | |||||
| "version": "0.0.0", | "version": "0.0.0", | ||||
| "license": "ISC", | "license": "ISC", | ||||
| "dependencies": { | "dependencies": { | ||||
| "@fontsource-variable/noto-sans-jp": "^5.2.9", | |||||
| "@radix-ui/react-dialog": "^1.1.14", | "@radix-ui/react-dialog": "^1.1.14", | ||||
| "@radix-ui/react-switch": "^1.2.5", | "@radix-ui/react-switch": "^1.2.5", | ||||
| "@radix-ui/react-toast": "^1.2.14", | "@radix-ui/react-toast": "^1.2.14", | ||||
| @@ -16,6 +17,7 @@ | |||||
| "camelcase-keys": "^9.1.3", | "camelcase-keys": "^9.1.3", | ||||
| "class-variance-authority": "^0.7.1", | "class-variance-authority": "^0.7.1", | ||||
| "clsx": "^2.1.1", | "clsx": "^2.1.1", | ||||
| "framer-motion": "^12.23.26", | |||||
| "humps": "^2.0.1", | "humps": "^2.0.1", | ||||
| "lucide-react": "^0.511.0", | "lucide-react": "^0.511.0", | ||||
| "markdown-it": "^14.1.0", | "markdown-it": "^14.1.0", | ||||
| @@ -947,6 +949,15 @@ | |||||
| "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | "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": { | "node_modules/@humanfs/core": { | ||||
| "version": "0.19.1", | "version": "0.19.1", | ||||
| "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", | "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", | ||||
| @@ -3563,6 +3574,33 @@ | |||||
| "url": "https://github.com/sponsors/rawify" | "url": "https://github.com/sponsors/rawify" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/framer-motion": { | |||||
| "version": "12.23.26", | |||||
| "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", | |||||
| "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "motion-dom": "^12.23.23", | |||||
| "motion-utils": "^12.23.6", | |||||
| "tslib": "^2.4.0" | |||||
| }, | |||||
| "peerDependencies": { | |||||
| "@emotion/is-prop-valid": "*", | |||||
| "react": "^18.0.0 || ^19.0.0", | |||||
| "react-dom": "^18.0.0 || ^19.0.0" | |||||
| }, | |||||
| "peerDependenciesMeta": { | |||||
| "@emotion/is-prop-valid": { | |||||
| "optional": true | |||||
| }, | |||||
| "react": { | |||||
| "optional": true | |||||
| }, | |||||
| "react-dom": { | |||||
| "optional": true | |||||
| } | |||||
| } | |||||
| }, | |||||
| "node_modules/fsevents": { | "node_modules/fsevents": { | ||||
| "version": "2.3.3", | "version": "2.3.3", | ||||
| "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", | ||||
| @@ -5206,6 +5244,21 @@ | |||||
| "node": ">=16 || 14 >=14.17" | "node": ">=16 || 14 >=14.17" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/motion-dom": { | |||||
| "version": "12.23.23", | |||||
| "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", | |||||
| "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "motion-utils": "^12.23.6" | |||||
| } | |||||
| }, | |||||
| "node_modules/motion-utils": { | |||||
| "version": "12.23.6", | |||||
| "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", | |||||
| "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", | |||||
| "license": "MIT" | |||||
| }, | |||||
| "node_modules/ms": { | "node_modules/ms": { | ||||
| "version": "2.1.3", | "version": "2.1.3", | ||||
| "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | ||||
| @@ -11,6 +11,7 @@ | |||||
| "preview": "vite preview" | "preview": "vite preview" | ||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| "@fontsource-variable/noto-sans-jp": "^5.2.9", | |||||
| "@radix-ui/react-dialog": "^1.1.14", | "@radix-ui/react-dialog": "^1.1.14", | ||||
| "@radix-ui/react-switch": "^1.2.5", | "@radix-ui/react-switch": "^1.2.5", | ||||
| "@radix-ui/react-toast": "^1.2.14", | "@radix-ui/react-toast": "^1.2.14", | ||||
| @@ -18,6 +19,7 @@ | |||||
| "camelcase-keys": "^9.1.3", | "camelcase-keys": "^9.1.3", | ||||
| "class-variance-authority": "^0.7.1", | "class-variance-authority": "^0.7.1", | ||||
| "clsx": "^2.1.1", | "clsx": "^2.1.1", | ||||
| "framer-motion": "^12.23.26", | |||||
| "humps": "^2.0.1", | "humps": "^2.0.1", | ||||
| "lucide-react": "^0.511.0", | "lucide-react": "^0.511.0", | ||||
| "markdown-it": "^14.1.0", | "markdown-it": "^14.1.0", | ||||
| @@ -19,7 +19,6 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`, | |||||
| { params: { ...(tagName && { tags: tagName, | { params: { ...(tagName && { tags: tagName, | ||||
| match: 'all', | match: 'all', | ||||
| limit: '20' }) } })).data.posts | limit: '20' }) } })).data.posts | ||||
| const fetchPostIds = async () => (await fetchPosts ()).map (post => post.id) | |||||
| const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data | const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data | ||||
| const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name) | const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name) | ||||
| @@ -33,7 +32,7 @@ const createPostListOutlet = async tagName => ` | |||||
| <div class="flex gap-4"><a href="#" class="font-bold">広場</a></div> | <div class="flex gap-4"><a href="#" class="font-bold">広場</a></div> | ||||
| <div class="mt-2"> | <div class="mt-2"> | ||||
| <div class="flex flex-wrap gap-6 p-4"> | <div class="flex flex-wrap gap-6 p-4"> | ||||
| ${ (await fetchPosts (tagName)).map (post => ` | |||||
| ${ (await fetchPosts (tagName)).slice (0, 20).map (post => ` | |||||
| <a class="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg" | <a class="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg" | ||||
| href="/posts/${ post.id }"> | href="/posts/${ post.id }"> | ||||
| <img alt="${ post.title }" | <img alt="${ post.title }" | ||||
| @@ -42,7 +41,7 @@ const createPostListOutlet = async tagName => ` | |||||
| fetchpriority="high" | fetchpriority="high" | ||||
| decoding="async" | decoding="async" | ||||
| class="object-none w-full h-full" | class="object-none w-full h-full" | ||||
| src="${ post.url }" /> | |||||
| src="${ post.thumbnail }" /> | |||||
| </a>`).join ('') } | </a>`).join ('') } | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -9,6 +9,7 @@ import { API_BASE_URL } from '@/config' | |||||
| import NicoTagListPage from '@/pages/tags/NicoTagListPage' | import NicoTagListPage from '@/pages/tags/NicoTagListPage' | ||||
| import NotFound from '@/pages/NotFound' | import NotFound from '@/pages/NotFound' | ||||
| import PostDetailPage from '@/pages/posts/PostDetailPage' | import PostDetailPage from '@/pages/posts/PostDetailPage' | ||||
| import PostHistoryPage from '@/pages/posts/PostHistoryPage' | |||||
| import PostListPage from '@/pages/posts/PostListPage' | import PostListPage from '@/pages/posts/PostListPage' | ||||
| import PostNewPage from '@/pages/posts/PostNewPage' | import PostNewPage from '@/pages/posts/PostNewPage' | ||||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | import ServiceUnavailable from '@/pages/ServiceUnavailable' | ||||
| @@ -79,6 +80,7 @@ export default (() => { | |||||
| <Route path="/posts" element={<PostListPage/>}/> | <Route path="/posts" element={<PostListPage/>}/> | ||||
| <Route path="/posts/new" element={<PostNewPage user={user}/>}/> | <Route path="/posts/new" element={<PostNewPage user={user}/>}/> | ||||
| <Route path="/posts/:id" element={<PostDetailPage user={user}/>}/> | <Route path="/posts/:id" element={<PostDetailPage user={user}/>}/> | ||||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | |||||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | ||||
| <Route path="/wiki" element={<WikiSearchPage/>}/> | <Route path="/wiki" element={<WikiSearchPage/>}/> | ||||
| <Route path="/wiki/:title" element={<WikiDetailPage/>}/> | <Route path="/wiki/:title" element={<WikiDetailPage/>}/> | ||||
| @@ -1,37 +1,65 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
| import { useState } from 'react' | |||||
| import { useEffect, useState } from 'react' | |||||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | import PostFormTagsArea from '@/components/PostFormTagsArea' | ||||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | |||||
| import Label from '@/components/common/Label' | import Label from '@/components/common/Label' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import type { Post } from '@/types' | |||||
| import type { Post, Tag } from '@/types' | |||||
| const tagsToStr = (tags: Tag[]): string => { | |||||
| const result: Tag[] = [] | |||||
| const walk = (tag: Tag) => { | |||||
| const { children, ...rest } = tag | |||||
| result.push (rest) | |||||
| children?.forEach (walk) | |||||
| } | |||||
| tags.filter (t => t.category !== 'nico').forEach (walk) | |||||
| return [...(new Set (result.map (t => t.name)))].join (' ') | |||||
| } | |||||
| type Props = { post: Post | type Props = { post: Post | ||||
| onSave: (newPost: Post) => void } | onSave: (newPost: Post) => void } | ||||
| export default (({ post, onSave }: Props) => { | export default (({ post, onSave }: Props) => { | ||||
| const [originalCreatedBefore, setOriginalCreatedBefore] = | |||||
| useState<string | null> (post.originalCreatedBefore) | |||||
| const [originalCreatedFrom, setOriginalCreatedFrom] = | |||||
| useState<string | null> (post.originalCreatedFrom) | |||||
| const [title, setTitle] = useState (post.title) | const [title, setTitle] = useState (post.title) | ||||
| const [tags, setTags] = useState<string> (post.tags | |||||
| .filter (t => t.category !== 'nico') | |||||
| .map (t => t.name) | |||||
| .join (' ')) | |||||
| const [tags, setTags] = useState<string> ('') | |||||
| const handleSubmit = async () => { | const 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', | { 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 | const data = toCamel (res.data as any, { deep: true }) as Post | ||||
| onSave ({ ...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 ( | return ( | ||||
| <div className="max-w-xl pt-2 space-y-4"> | <div className="max-w-xl pt-2 space-y-4"> | ||||
| {/* タイトル */} | {/* タイトル */} | ||||
| @@ -40,12 +68,19 @@ export default (({ post, onSave }: Props) => { | |||||
| <input type="text" | <input type="text" | ||||
| className="w-full border rounded p-2" | className="w-full border rounded p-2" | ||||
| value={title} | value={title} | ||||
| onChange={e => setTitle (e.target.value)}/> | |||||
| onChange={ev => setTitle (ev.target.value)}/> | |||||
| </div> | </div> | ||||
| {/* タグ */} | {/* タグ */} | ||||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | <PostFormTagsArea tags={tags} setTags={setTags}/> | ||||
| {/* オリジナルの作成日時 */} | |||||
| <PostOriginalCreatedTimeField | |||||
| originalCreatedFrom={originalCreatedFrom} | |||||
| setOriginalCreatedFrom={setOriginalCreatedFrom} | |||||
| originalCreatedBefore={originalCreatedBefore} | |||||
| setOriginalCreatedBefore={setOriginalCreatedBefore}/> | |||||
| {/* 送信 */} | {/* 送信 */} | ||||
| <Button onClick={handleSubmit} | <Button onClick={handleSubmit} | ||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | ||||
| @@ -12,15 +12,14 @@ export default (({ posts, onClick }: Props) => ( | |||||
| <div className="flex flex-wrap gap-6 p-4"> | <div className="flex flex-wrap gap-6 p-4"> | ||||
| {posts.map ((post, i) => ( | {posts.map ((post, i) => ( | ||||
| <Link to={`/posts/${ post.id }`} | <Link to={`/posts/${ post.id }`} | ||||
| key={i} | |||||
| key={post.id} | |||||
| className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg" | className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg" | ||||
| onClick={onClick}> | onClick={onClick}> | ||||
| <img src={post.thumbnail || post.thumbnailBase || undefined} | <img src={post.thumbnail || post.thumbnailBase || undefined} | ||||
| alt={post.title || post.url} | alt={post.title || post.url} | ||||
| title={post.title || post.url || undefined} | title={post.title || post.url || undefined} | ||||
| loading="eager" | |||||
| fetchPriority="high" | |||||
| loading={i < 12 ? 'eager' : 'lazy'} | |||||
| decoding="async" | decoding="async" | ||||
| className="object-none w-full h-full" /> | |||||
| className="object-cover w-full h-full"/> | |||||
| </Link>))} | </Link>))} | ||||
| </div>)) satisfies FC<Props> | </div>)) satisfies FC<Props> | ||||
| @@ -0,0 +1,49 @@ | |||||
| import DateTimeField from '@/components/common/DateTimeField' | |||||
| import Label from '@/components/common/Label' | |||||
| import type { FC } from 'react' | |||||
| type Props = { | |||||
| originalCreatedFrom: string | null | |||||
| setOriginalCreatedFrom: (x: string | null) => void | |||||
| originalCreatedBefore: string | null | |||||
| setOriginalCreatedBefore: (x: string | null) => void } | |||||
| export default (({ originalCreatedFrom, | |||||
| setOriginalCreatedFrom, | |||||
| originalCreatedBefore, | |||||
| setOriginalCreatedBefore }: Props) => ( | |||||
| <div> | |||||
| <Label>オリジナルの作成日時</Label> | |||||
| <div className="my-1"> | |||||
| <DateTimeField | |||||
| className="mr-2" | |||||
| value={originalCreatedFrom ?? undefined} | |||||
| onChange={setOriginalCreatedFrom} | |||||
| onBlur={ev => { | |||||
| const v = ev.target.value | |||||
| if (!(v)) | |||||
| return | |||||
| const d = new Date (v) | |||||
| if (d.getSeconds () === 0) | |||||
| { | |||||
| if (d.getMinutes () === 0 && d.getHours () === 0) | |||||
| d.setDate (d.getDate () + 1) | |||||
| else | |||||
| d.setMinutes (d.getMinutes () + 1) | |||||
| } | |||||
| else | |||||
| d.setSeconds (d.getSeconds () + 1) | |||||
| setOriginalCreatedBefore (d.toISOString ()) | |||||
| }}/> | |||||
| 以降 | |||||
| </div> | |||||
| <div className="my-1"> | |||||
| <DateTimeField | |||||
| className="mr-2" | |||||
| value={originalCreatedBefore ?? undefined} | |||||
| onChange={setOriginalCreatedBefore}/> | |||||
| より前 | |||||
| </div> | |||||
| </div>)) satisfies FC<Props> | |||||
| @@ -1,17 +1,45 @@ | |||||
| import { AnimatePresence, motion } from 'framer-motion' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Link } from 'react-router-dom' | |||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| import TagSearch from '@/components/TagSearch' | import TagSearch from '@/components/TagSearch' | ||||
| import SectionTitle from '@/components/common/SectionTitle' | |||||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | import SubsectionTitle from '@/components/common/SubsectionTitle' | ||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| import { CATEGORIES } from '@/consts' | import { CATEGORIES } from '@/consts' | ||||
| import type { FC } from 'react' | |||||
| import type { FC, ReactNode } from 'react' | |||||
| import type { Category, Post, Tag } from '@/types' | import type { Category, Post, Tag } from '@/types' | ||||
| type TagByCategory = { [key in Category]: Tag[] } | type TagByCategory = { [key in Category]: Tag[] } | ||||
| const renderTagTree = ( | |||||
| tag: Tag, | |||||
| nestLevel: number, | |||||
| path: string, | |||||
| ): ReactNode[] => { | |||||
| const key = `${ path }-${ tag.id }` | |||||
| const self = ( | |||||
| <motion.li | |||||
| key={key} | |||||
| layout | |||||
| transition={{ duration: .2, ease: 'easeOut' }} | |||||
| className="mb-1"> | |||||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||||
| </motion.li>) | |||||
| return [self, | |||||
| ...((tag.children | |||||
| ?.sort ((a, b) => a.name < b.name ? -1 : 1) | |||||
| .flatMap (child => renderTagTree (child, nestLevel + 1, key))) | |||||
| ?? [])] | |||||
| } | |||||
| type Props = { post: Post | null } | type Props = { post: Post | null } | ||||
| @@ -49,15 +77,63 @@ export default (({ post }: Props) => { | |||||
| return ( | return ( | ||||
| <SidebarComponent> | <SidebarComponent> | ||||
| <TagSearch/> | <TagSearch/> | ||||
| {CATEGORIES.map ((cat: Category) => cat in tags && ( | |||||
| <div className="my-3" key={cat}> | |||||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||||
| <ul> | |||||
| {tags[cat].map ((tag, i) => ( | |||||
| <li key={i} className="mb-1"> | |||||
| <TagLink tag={tag}/> | |||||
| </li>))} | |||||
| </ul> | |||||
| </div>))} | |||||
| <motion.div key={post?.id ?? 0} layout> | |||||
| {CATEGORIES.map ((cat: Category) => cat in tags && ( | |||||
| <motion.div layout className="my-3" key={cat}> | |||||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||||
| <motion.ul layout> | |||||
| <AnimatePresence initial={false}> | |||||
| {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))} | |||||
| </AnimatePresence> | |||||
| </motion.ul> | |||||
| </motion.div>))} | |||||
| {post && ( | |||||
| <div> | |||||
| <SectionTitle>情報</SectionTitle> | |||||
| <ul> | |||||
| <li>Id.: {post.id}</li> | |||||
| {/* TODO: uploadedUser の取得を対応したらコメント外す */} | |||||
| {/* | |||||
| <li> | |||||
| <>耕作者: </> | |||||
| {post.uploadedUser | |||||
| ? ( | |||||
| <Link to={`/users/${ post.uploadedUser.id }`}> | |||||
| {post.uploadedUser.name || '名もなきニジラー'} | |||||
| </Link>) | |||||
| : 'bot操作'} | |||||
| </li> | |||||
| */} | |||||
| <li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li> | |||||
| <li> | |||||
| <>リンク: </> | |||||
| <a | |||||
| className="break-all" | |||||
| href={post.url} | |||||
| target="_blank" | |||||
| rel="noopener noreferrer nofollow"> | |||||
| {post.url} | |||||
| </a> | |||||
| </li> | |||||
| <li> | |||||
| {/* TODO: 表示形式きしょすぎるので何とかする */} | |||||
| <>オリジナルの投稿日時: </> | |||||
| {!(post.originalCreatedFrom) && !(post.originalCreatedBefore) | |||||
| ? '不明' | |||||
| : ( | |||||
| <> | |||||
| {post.originalCreatedFrom | |||||
| && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} | |||||
| {post.originalCreatedBefore | |||||
| && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} | |||||
| </>)} | |||||
| </li> | |||||
| <li> | |||||
| <Link to={`/posts/changes?id=${ post.id }`}>履歴</Link> | |||||
| </li> | |||||
| </ul> | |||||
| </div>)} | |||||
| </motion.div> | |||||
| </SidebarComponent>) | </SidebarComponent>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -1,5 +1,8 @@ | |||||
| import axios from 'axios' | |||||
| import { useEffect, useState } from 'react' | |||||
| import { Link } from 'react-router-dom' | import { Link } from 'react-router-dom' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' | import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' | ||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| @@ -8,6 +11,7 @@ import type { ComponentProps, FC, HTMLAttributes } from 'react' | |||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| type CommonProps = { tag: Tag | type CommonProps = { tag: Tag | ||||
| nestLevel?: number | |||||
| withWiki?: boolean | withWiki?: boolean | ||||
| withCount?: boolean } | withCount?: boolean } | ||||
| @@ -21,10 +25,32 @@ type Props = PropsWithLink | PropsWithoutLink | |||||
| export default (({ tag, | export default (({ tag, | ||||
| nestLevel = 0, | |||||
| linkFlg = true, | linkFlg = true, | ||||
| withWiki = true, | withWiki = true, | ||||
| withCount = true, | withCount = true, | ||||
| ...props }: Props) => { | ...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 ( | const spanClass = cn ( | ||||
| `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, | `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, | ||||
| `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) | `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) | ||||
| @@ -37,10 +63,25 @@ export default (({ tag, | |||||
| <> | <> | ||||
| {(linkFlg && withWiki) && ( | {(linkFlg && withWiki) && ( | ||||
| <span className="mr-1"> | <span className="mr-1"> | ||||
| <Link to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||||
| className={linkClass}> | |||||
| ? | |||||
| </Link> | |||||
| {havingWiki | |||||
| ? ( | |||||
| <Link to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||||
| className={linkClass}> | |||||
| ? | |||||
| </Link>) | |||||
| : ( | |||||
| <Link to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||||
| className="animate-[wiki-blink_.25s_steps(2,end)_infinite] | |||||
| dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]" | |||||
| title={`${ tag.name } Wiki が存在しません.`}> | |||||
| ! | |||||
| </Link>)} | |||||
| </span>)} | |||||
| {nestLevel > 0 && ( | |||||
| <span | |||||
| className="ml-1 mr-1" | |||||
| style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}> | |||||
| ↳ | |||||
| </span>)} | </span>)} | ||||
| {linkFlg | {linkFlg | ||||
| ? ( | ? ( | ||||
| @@ -1,4 +1,5 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import { AnimatePresence, motion } from 'framer-motion' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { useLocation, useNavigate } from 'react-router-dom' | import { useLocation, useNavigate } from 'react-router-dom' | ||||
| @@ -8,7 +9,6 @@ import SectionTitle from '@/components/common/SectionTitle' | |||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
| import { CATEGORIES } from '@/consts' | import { CATEGORIES } from '@/consts' | ||||
| import { cn } from '@/lib/utils' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| @@ -58,47 +58,71 @@ export default (({ posts }: Props) => { | |||||
| setTags (tagsTmp) | setTags (tagsTmp) | ||||
| }, [posts]) | }, [posts]) | ||||
| const TagBlock = ( | |||||
| <> | |||||
| <SectionTitle>タグ</SectionTitle> | |||||
| <ul> | |||||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | |||||
| tags[cat].map (tag => ( | |||||
| <li key={tag.id} className="mb-1"> | |||||
| <TagLink tag={tag}/> | |||||
| </li>))) : [])} | |||||
| </ul> | |||||
| <SectionTitle>関聯</SectionTitle> | |||||
| {posts.length > 0 && ( | |||||
| <a href="#" | |||||
| onClick={ev => { | |||||
| ev.preventDefault () | |||||
| void ((async () => { | |||||
| try | |||||
| { | |||||
| const { data } = await axios.get (`${ API_BASE_URL }/posts/random`, | |||||
| { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), | |||||
| match: (anyFlg ? 'any' : 'all') } }) | |||||
| navigate (`/posts/${ (data as Post).id }`) | |||||
| } | |||||
| catch | |||||
| { | |||||
| ; | |||||
| } | |||||
| }) ()) | |||||
| }}> | |||||
| ランダム | |||||
| </a>)} | |||||
| </>) | |||||
| return ( | return ( | ||||
| <SidebarComponent> | <SidebarComponent> | ||||
| <TagSearch/> | <TagSearch/> | ||||
| <div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}> | |||||
| <SectionTitle>タグ</SectionTitle> | |||||
| <ul> | |||||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | |||||
| tags[cat].map (tag => ( | |||||
| <li key={tag.id} className="mb-1"> | |||||
| <TagLink tag={tag}/> | |||||
| </li>))) : [])} | |||||
| </ul> | |||||
| <SectionTitle>関聯</SectionTitle> | |||||
| {posts.length > 0 && ( | |||||
| <a href="#" | |||||
| onClick={ev => { | |||||
| ev.preventDefault () | |||||
| void ((async () => { | |||||
| try | |||||
| { | |||||
| const { data } = await axios.get (`${ API_BASE_URL }/posts/random`, | |||||
| { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (','), | |||||
| match: (anyFlg ? 'any' : 'all') } }) | |||||
| navigate (`/posts/${ (data as Post).id }`) | |||||
| } | |||||
| catch | |||||
| { | |||||
| ; | |||||
| } | |||||
| }) ()) | |||||
| }}> | |||||
| ランダム | |||||
| </a>)} | |||||
| <div className="hidden md:block mt-4"> | |||||
| {TagBlock} | |||||
| </div> | </div> | ||||
| <AnimatePresence initial={false}> | |||||
| {tagsVsbl && ( | |||||
| <motion.div | |||||
| key="sptags" | |||||
| className="md:hidden mt-4" | |||||
| variants={{ hidden: { clipPath: 'inset(0 0 100% 0)', | |||||
| height: 0 }, | |||||
| visible: { clipPath: 'inset(0 0 0% 0)', | |||||
| height: 'auto'} }} | |||||
| initial="hidden" | |||||
| animate="visible" | |||||
| exit="hidden" | |||||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||||
| {TagBlock} | |||||
| </motion.div>)} | |||||
| </AnimatePresence> | |||||
| <a href="#" | <a href="#" | ||||
| className="md:hidden block my-2 text-center text-sm | className="md:hidden block my-2 text-center text-sm | ||||
| text-gray-500 hover:text-gray-400 | text-gray-500 hover:text-gray-400 | ||||
| dark:text-gray-300 dark:hover:text-gray-100" | dark:text-gray-300 dark:hover:text-gray-100" | ||||
| onClick={ev => { | onClick={ev => { | ||||
| ev.preventDefault () | ev.preventDefault () | ||||
| setTagsVsbl (!(tagsVsbl)) | |||||
| setTagsVsbl (v => !(v)) | |||||
| }}> | }}> | ||||
| {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} | {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} | ||||
| </a> | </a> | ||||
| @@ -1,6 +1,7 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
| import { Fragment, useState, useEffect } from 'react' | |||||
| import { AnimatePresence, motion } from 'framer-motion' | |||||
| import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' | |||||
| import { Link, useLocation } from 'react-router-dom' | import { Link, useLocation } from 'react-router-dom' | ||||
| import Separator from '@/components/MenuSeparator' | import Separator from '@/components/MenuSeparator' | ||||
| @@ -19,6 +20,28 @@ type Props = { user: User | null } | |||||
| export default (({ user }: Props) => { | export default (({ user }: Props) => { | ||||
| const location = useLocation () | const location = useLocation () | ||||
| const dirRef = useRef<(-1) | 1> (1) | |||||
| const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([]) | |||||
| const navRef = useRef<HTMLDivElement | null> (null) | |||||
| const measure = () => { | |||||
| const nav = navRef.current | |||||
| const el = itemsRef.current[activeIdx] | |||||
| if (!(nav) || !(el) || activeIdx < 0) | |||||
| return | |||||
| const navRect = nav.getBoundingClientRect () | |||||
| const elRect = el.getBoundingClientRect () | |||||
| setHl ({ left: elRect.left - navRect.left, | |||||
| width: elRect.width, | |||||
| visible: true }) | |||||
| } | |||||
| const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({ | |||||
| left: 0, | |||||
| width: 0, | |||||
| visible: false }) | |||||
| const [menuOpen, setMenuOpen] = useState (false) | const [menuOpen, setMenuOpen] = useState (false) | ||||
| const [openItemIdx, setOpenItemIdx] = useState (-1) | const [openItemIdx, setOpenItemIdx] = useState (-1) | ||||
| const [postCount, setPostCount] = useState<number | null> (null) | const [postCount, setPostCount] = useState<number | null> (null) | ||||
| @@ -30,13 +53,13 @@ export default (({ user }: Props) => { | |||||
| { name: '広場', to: '/posts', subMenu: [ | { name: '広場', to: '/posts', subMenu: [ | ||||
| { name: '一覧', to: '/posts' }, | { name: '一覧', to: '/posts' }, | ||||
| { name: '投稿追加', to: '/posts/new' }, | { name: '投稿追加', to: '/posts/new' }, | ||||
| { name: '耕作履歴', to: '/posts/changes' }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | ||||
| { name: 'タグ', to: '/tags', subMenu: [ | { name: 'タグ', to: '/tags', subMenu: [ | ||||
| { name: 'タグ一覧', to: '/tags', visible: false }, | { name: 'タグ一覧', to: '/tags', visible: false }, | ||||
| { name: '別名タグ', to: '/tags/aliases', visible: false }, | { name: '別名タグ', to: '/tags/aliases', visible: false }, | ||||
| { name: '上位タグ', to: '/tags/implications', visible: false }, | { name: '上位タグ', to: '/tags/implications', visible: false }, | ||||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | { name: 'ニコニコ連携', to: '/tags/nico' }, | ||||
| { name: 'タグのつけ方', to: '/wiki/ヘルプ:タグのつけ方' }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, | ||||
| { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ | { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ | ||||
| { name: '検索', to: '/wiki' }, | { name: '検索', to: '/wiki' }, | ||||
| @@ -53,6 +76,32 @@ export default (({ user }: Props) => { | |||||
| { name: 'お前', to: `/users/${ user?.id }`, visible: false }, | { name: 'お前', to: `/users/${ user?.id }`, visible: false }, | ||||
| { name: '設定', to: '/users/settings', visible: Boolean (user) }] }] | { name: '設定', to: '/users/settings', visible: Boolean (user) }] }] | ||||
| const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to)) | |||||
| const prevActiveIdxRef = useRef<number> (activeIdx) | |||||
| if (activeIdx !== prevActiveIdxRef.current) | |||||
| { | |||||
| dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1 | |||||
| prevActiveIdxRef.current = activeIdx | |||||
| } | |||||
| const dir = dirRef.current | |||||
| useLayoutEffect (() => { | |||||
| if (activeIdx < 0) | |||||
| return | |||||
| const raf = requestAnimationFrame (measure) | |||||
| const onResize = () => requestAnimationFrame (measure) | |||||
| addEventListener ('resize', onResize) | |||||
| return () => { | |||||
| cancelAnimationFrame (raf) | |||||
| removeEventListener ('resize', onResize) | |||||
| } | |||||
| }, [activeIdx]) | |||||
| useEffect (() => { | useEffect (() => { | ||||
| const unsubscribe = WikiIdBus.subscribe (setWikiId) | const unsubscribe = WikiIdBus.subscribe (setWikiId) | ||||
| return () => unsubscribe () | return () => unsubscribe () | ||||
| @@ -98,16 +147,26 @@ export default (({ user }: Props) => { | |||||
| ぼざクリ タグ広場 | ぼざクリ タグ広場 | ||||
| </Link> | </Link> | ||||
| {menu.map ((item, i) => ( | |||||
| <Link key={i} | |||||
| to={item.to} | |||||
| className={cn ('hidden md:flex h-full items-center', | |||||
| (location.pathname.startsWith (item.base || item.to) | |||||
| ? 'bg-yellow-200 dark:bg-red-950 px-4 font-bold' | |||||
| : 'px-2'))}> | |||||
| {item.name} | |||||
| </Link> | |||||
| ))} | |||||
| <div ref={navRef} className="relative hidden md:flex h-full items-center"> | |||||
| <div aria-hidden | |||||
| className={cn ('absolute top-1/2 -translate-y-1/2 h-full', | |||||
| 'bg-yellow-200 dark:bg-red-950', | |||||
| 'transition-[transform,width] duration-200 ease-out')} | |||||
| style={{ width: hl.width, | |||||
| transform: `translate(${ hl.left }px, -50%)`, | |||||
| opacity: hl.visible ? 1 : 0 }}/> | |||||
| {menu.map ((item, i) => ( | |||||
| <Link key={i} | |||||
| to={item.to} | |||||
| ref={el => { | |||||
| itemsRef.current[i] = el | |||||
| }} | |||||
| className={cn ('relative z-10 flex h-full items-center px-5', | |||||
| (i === openItemIdx) && 'font-bold')}> | |||||
| {item.name} | |||||
| </Link>))} | |||||
| </div> | |||||
| </div> | </div> | ||||
| <TopNavUser user={user}/> | <TopNavUser user={user}/> | ||||
| @@ -124,49 +183,101 @@ export default (({ user }: Props) => { | |||||
| </a> | </a> | ||||
| </nav> | </nav> | ||||
| <div className="hidden md:flex bg-yellow-200 dark:bg-red-950 | |||||
| items-center w-full min-h-[40px] px-3"> | |||||
| {menu.find (item => location.pathname.startsWith (item.base || item.to))?.subMenu | |||||
| .filter (item => item.visible ?? true) | |||||
| .map ((item, i) => 'component' in item ? item.component : ( | |||||
| <Link key={i} | |||||
| to={item.to} | |||||
| className="h-full flex items-center px-3"> | |||||
| {item.name} | |||||
| </Link>))} | |||||
| <div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950 | |||||
| items-center w-full min-h-[40px] overflow-hidden"> | |||||
| <AnimatePresence initial={false} custom={dir}> | |||||
| <motion.div | |||||
| key={activeIdx} | |||||
| custom={dir} | |||||
| variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }), | |||||
| centre: { y: 0, opacity: 1 }, | |||||
| exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }} | |||||
| className="absolute inset-0 flex items-center px-3" | |||||
| initial="enter" | |||||
| animate="centre" | |||||
| exit="exit" | |||||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||||
| {(menu[activeIdx]?.subMenu ?? []) | |||||
| .filter (item => item.visible ?? true) | |||||
| .map ((item, i) => ( | |||||
| 'component' in item | |||||
| ? <Fragment key={`c-${ i }`}>{item.component}</Fragment> | |||||
| : ( | |||||
| <Link key={`l-${ i }`} | |||||
| to={item.to} | |||||
| className="h-full flex items-center px-3"> | |||||
| {item.name} | |||||
| </Link>)))} | |||||
| </motion.div> | |||||
| </AnimatePresence> | |||||
| </div> | </div> | ||||
| <div className={cn (menuOpen ? 'flex flex-col md:hidden' : 'hidden', | |||||
| 'bg-yellow-200 dark:bg-red-975 items-start')}> | |||||
| <Separator/> | |||||
| {menu.map ((item, i) => ( | |||||
| <Fragment key={i}> | |||||
| <Link to={i === openItemIdx ? item.to : '#'} | |||||
| className={cn ('w-full min-h-[40px] flex items-center pl-8', | |||||
| ((i === openItemIdx) | |||||
| && 'font-bold bg-yellow-50 dark:bg-red-950'))} | |||||
| onClick={ev => { | |||||
| if (i !== openItemIdx) | |||||
| { | |||||
| ev.preventDefault () | |||||
| setOpenItemIdx (i) | |||||
| } | |||||
| }}> | |||||
| {item.name} | |||||
| </Link> | |||||
| {i === openItemIdx && ( | |||||
| item.subMenu | |||||
| .filter (subItem => subItem.visible ?? true) | |||||
| .map ((subItem, j) => 'component' in subItem ? subItem.component : ( | |||||
| <Link key={j} | |||||
| to={subItem.to} | |||||
| className="w-full min-h-[36px] flex items-center pl-12 | |||||
| bg-yellow-50 dark:bg-red-950"> | |||||
| {subItem.name} | |||||
| </Link>)))} | |||||
| </Fragment>))} | |||||
| <TopNavUser user={user} sp/> | |||||
| <Separator/> | |||||
| </div> | |||||
| <AnimatePresence initial={false}> | |||||
| {menuOpen && ( | |||||
| <motion.div | |||||
| key="spmenu" | |||||
| className={cn ('flex flex-col md:hidden', | |||||
| 'bg-yellow-200 dark:bg-red-975 items-start')} | |||||
| variants={{ closed: { clipPath: 'inset(0 0 100% 0)', | |||||
| height: 0 }, | |||||
| open: { clipPath: 'inset(0 0 0% 0)', | |||||
| height: 'auto' } }} | |||||
| initial="closed" | |||||
| animate="open" | |||||
| exit="closed" | |||||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||||
| <Separator/> | |||||
| {menu.map ((item, i) => ( | |||||
| <Fragment key={i}> | |||||
| <Link to={i === openItemIdx ? item.to : '#'} | |||||
| className={cn ('w-full min-h-[40px] flex items-center pl-8', | |||||
| ((i === openItemIdx) | |||||
| && 'font-bold bg-yellow-50 dark:bg-red-950'))} | |||||
| onClick={ev => { | |||||
| if (i !== openItemIdx) | |||||
| { | |||||
| ev.preventDefault () | |||||
| setOpenItemIdx (i) | |||||
| } | |||||
| }}> | |||||
| {item.name} | |||||
| </Link> | |||||
| <AnimatePresence initial={false}> | |||||
| {i === openItemIdx && ( | |||||
| <motion.div | |||||
| key={`sp-sub-${ i }`} | |||||
| className="w-full bg-yellow-50 dark:bg-red-950" | |||||
| variants={{ closed: { clipPath: 'inset(0 0 100% 0)', | |||||
| height: 0, | |||||
| opacity: 0 }, | |||||
| open: { clipPath: 'inset(0 0 0% 0)', | |||||
| height: 'auto', | |||||
| opacity: 1 } }} | |||||
| initial="closed" | |||||
| animate="open" | |||||
| exit="closed" | |||||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||||
| {item.subMenu | |||||
| .filter (subItem => subItem.visible ?? true) | |||||
| .map ((subItem, j) => ( | |||||
| 'component' in subItem | |||||
| ? ( | |||||
| <Fragment key={`sp-c-${ i }-${ j }`}> | |||||
| {subItem.component} | |||||
| </Fragment>) | |||||
| : ( | |||||
| <Link key={`sp-l-${ i }-${ j }`} | |||||
| to={subItem.to} | |||||
| className="w-full min-h-[36px] flex items-center pl-12"> | |||||
| {subItem.name} | |||||
| </Link>)))} | |||||
| </motion.div>)} | |||||
| </AnimatePresence> | |||||
| </Fragment>))} | |||||
| <TopNavUser user={user} sp/> | |||||
| <Separator/> | |||||
| </motion.div>)} | |||||
| </AnimatePresence> | |||||
| </>) | </>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -0,0 +1,48 @@ | |||||
| import { useEffect, useState } from 'react' | |||||
| import { cn } from '@/lib/utils' | |||||
| import type { FC, FocusEvent } from 'react' | |||||
| const pad = (n: number) => n.toString ().padStart (2, '0') | |||||
| const toDateTimeLocalValue = (d: Date) => { | |||||
| const y = d.getFullYear () | |||||
| const m = pad (d.getMonth () + 1) | |||||
| const day = pad (d.getDate ()) | |||||
| const h = pad (d.getHours ()) | |||||
| const min = pad (d.getMinutes ()) | |||||
| const s = pad (d.getSeconds ()) | |||||
| return `${ y }-${ m }-${ day }T${ h }:${ min }:${ s }` | |||||
| } | |||||
| type Props = { | |||||
| value?: string | |||||
| onChange?: (isoUTC: string | null) => void | |||||
| className?: string | |||||
| onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } | |||||
| export default (({ value, onChange, className, onBlur }: Props) => { | |||||
| const [local, setLocal] = useState ('') | |||||
| useEffect (() => { | |||||
| setLocal (value ? toDateTimeLocalValue (new Date (value)) : '') | |||||
| }, [value]) | |||||
| return ( | |||||
| <input | |||||
| className={cn ('border rounded p-2', className)} | |||||
| type="datetime-local" | |||||
| step={1} | |||||
| value={local} | |||||
| onChange={ev => { | |||||
| const v = ev.target.value | |||||
| setLocal (v) | |||||
| onChange?.(v ? (new Date (v)).toISOString () : null) | |||||
| }} | |||||
| onBlur={onBlur}/>) | |||||
| }) satisfies FC<Props> | |||||
| @@ -0,0 +1,79 @@ | |||||
| import { Link, useLocation } from 'react-router-dom' | |||||
| import type { FC } from 'react' | |||||
| type Props = { page: number | |||||
| totalPages: number | |||||
| siblingCount?: number } | |||||
| const range = (start: number, end: number): number[] => | |||||
| [...Array (end - start + 1).keys ()].map (i => start + i) | |||||
| const getPages = ( | |||||
| page: number, | |||||
| total: number, | |||||
| siblingCount: number, | |||||
| ): (number | '…')[] => { | |||||
| if (total <= 1) | |||||
| return [1] | |||||
| const first = 1 | |||||
| const last = total | |||||
| const left = Math.max (page - siblingCount, first) | |||||
| const right = Math.min (page + siblingCount, last) | |||||
| const pages: (number | '…')[] = [] | |||||
| pages.push (first) | |||||
| if (left > first + 1) | |||||
| pages.push ('…') | |||||
| const midStart = Math.max (left, first + 1) | |||||
| const midEnd = Math.min (right, last - 1) | |||||
| pages.push (...range (midStart, midEnd)) | |||||
| if (right < last - 1) | |||||
| pages.push ('…') | |||||
| if (last !== first) | |||||
| pages.push (last) | |||||
| return pages.filter ((v, i, arr) => i === 0 || v !== arr[i - 1]) | |||||
| } | |||||
| export default (({ page, totalPages, siblingCount = 4 }) => { | |||||
| const location = useLocation () | |||||
| const buildTo = (p: number) => { | |||||
| const qs = new URLSearchParams (location.search) | |||||
| qs.set ('page', String (p)) | |||||
| return `${ location.pathname }?${ qs.toString () }` | |||||
| } | |||||
| const pages = getPages (page, totalPages, siblingCount) | |||||
| return ( | |||||
| <nav className="mt-4 flex justify-center" aria-label="Pagination"> | |||||
| <div className="flex items-center gap-2"> | |||||
| {(page > 1) | |||||
| ? <Link to={buildTo (page - 1)} aria-label="前のページ"><</Link> | |||||
| : <span aria-hidden><</span>} | |||||
| {pages.map ((p, idx) => ( | |||||
| (p === '…') | |||||
| ? <span key={`dots-${ idx }`}>…</span> | |||||
| : ((p === page) | |||||
| ? <span key={p} className="font-bold" aria-current="page">{p}</span> | |||||
| : <Link key={p} to={buildTo (p)}>{p}</Link>)))} | |||||
| {(page < totalPages) | |||||
| ? <Link to={buildTo (page + 1)} aria-label="次のページ">></Link> | |||||
| : <span aria-hidden>></span>} | |||||
| </div> | |||||
| </nav>) | |||||
| }) satisfies FC<Props> | |||||
| @@ -1,3 +1,5 @@ | |||||
| @import "@fontsource-variable/noto-sans-jp"; | |||||
| @tailwind base; | @tailwind base; | ||||
| @tailwind components; | @tailwind components; | ||||
| @tailwind utilities; | @tailwind utilities; | ||||
| @@ -21,7 +23,7 @@ | |||||
| :root | :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; | line-height: 1.5; | ||||
| font-weight: 400; | font-weight: 400; | ||||
| @@ -94,3 +96,15 @@ button:focus-visible | |||||
| background-color: #f9f9f9; | background-color: #f9f9f9; | ||||
| } | } | ||||
| } | } | ||||
| @keyframes wiki-blink | |||||
| { | |||||
| 0%, 100% { color: #dc2626; } | |||||
| 50% { color: #2563eb; } | |||||
| } | |||||
| @keyframes wiki-blink-dark | |||||
| { | |||||
| 0%, 100% { color: #f87171; } | |||||
| 50% { color: #60a5fa; } | |||||
| } | |||||
| @@ -0,0 +1,104 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { useEffect, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | |||||
| import { Link, useLocation } from 'react-router-dom' | |||||
| import TagLink from '@/components/TagLink' | |||||
| import PageTitle from '@/components/common/PageTitle' | |||||
| import Pagination from '@/components/common/Pagination' | |||||
| import MainArea from '@/components/layout/MainArea' | |||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import type { FC } from 'react' | |||||
| import type { PostTagChange } from '@/types' | |||||
| export default (() => { | |||||
| const [changes, setChanges] = useState<PostTagChange[]> ([]) | |||||
| const [totalPages, setTotalPages] = useState<number> (0) | |||||
| const location = useLocation () | |||||
| const query = new URLSearchParams (location.search) | |||||
| const id = query.get ('id') | |||||
| const page = Number (query.get ('page') ?? 1) | |||||
| const limit = Number (query.get ('limit') ?? 20) | |||||
| // 投稿列の結合で使用 | |||||
| let rowsCnt: number | |||||
| useEffect (() => { | |||||
| void (async () => { | |||||
| const res = await axios.get (`${ API_BASE_URL }/posts/changes`, | |||||
| { params: { ...(id && { id }), page, limit } }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as { | |||||
| changes: PostTagChange[] | |||||
| count: number } | |||||
| setChanges (data.changes) | |||||
| setTotalPages (Math.ceil (data.count / limit)) | |||||
| }) () | |||||
| }, [id, page, limit]) | |||||
| return ( | |||||
| <MainArea> | |||||
| <Helmet> | |||||
| <title>{`耕作履歴 | ${ SITE_TITLE }`}</title> | |||||
| </Helmet> | |||||
| <PageTitle> | |||||
| 耕作履歴 | |||||
| {id && <>: 投稿 {<Link to={`/posts/${ id }`}>#{id}</Link>}</>} | |||||
| </PageTitle> | |||||
| <table className="table-auto w-full border-collapse"> | |||||
| <thead> | |||||
| <tr> | |||||
| <th className="p-2 text-left">投稿</th> | |||||
| <th className="p-2 text-left">変更</th> | |||||
| <th className="p-2 text-left">日時</th> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| {changes.map ((change, i) => { | |||||
| let withPost = i === 0 || change.post.id !== changes[i - 1].post.id | |||||
| if (withPost) | |||||
| { | |||||
| rowsCnt = 1 | |||||
| for (let j = i + 1; | |||||
| (j < changes.length | |||||
| && change.post.id === changes[j].post.id); | |||||
| ++j) | |||||
| ++rowsCnt | |||||
| } | |||||
| return ( | |||||
| <tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}> | |||||
| {withPost && ( | |||||
| <td className="align-top" rowSpan={rowsCnt}> | |||||
| <Link to={`/posts/${ change.post.id }`}> | |||||
| <img src={change.post.thumbnail || change.post.thumbnailBase || undefined} | |||||
| alt={change.post.title || change.post.url} | |||||
| title={change.post.title || change.post.url || undefined} | |||||
| className="w-40"/> | |||||
| </Link> | |||||
| </td>)} | |||||
| <td> | |||||
| <TagLink tag={change.tag} withWiki={false} withCount={false}/> | |||||
| {`を${ change.changeType === 'add' ? '追加' : '削除' }`} | |||||
| </td> | |||||
| <td> | |||||
| {change.user ? ( | |||||
| <Link to={`/users/${ change.user.id }`}> | |||||
| {change.user.name} | |||||
| </Link>) : 'bot 操作'} | |||||
| <br/> | |||||
| {change.timestamp} | |||||
| </td> | |||||
| </tr>) | |||||
| })} | |||||
| </tbody> | |||||
| </table> | |||||
| <Pagination page={page} totalPages={totalPages}/> | |||||
| </MainArea>) | |||||
| }) satisfies FC | |||||
| @@ -7,6 +7,7 @@ import { Link, useLocation, useNavigationType } from 'react-router-dom' | |||||
| import PostList from '@/components/PostList' | import PostList from '@/components/PostList' | ||||
| import TagSidebar from '@/components/TagSidebar' | import TagSidebar from '@/components/TagSidebar' | ||||
| import WikiBody from '@/components/WikiBody' | import WikiBody from '@/components/WikiBody' | ||||
| import Pagination from '@/components/common/Pagination' | |||||
| import TabGroup, { Tab } from '@/components/common/TabGroup' | import TabGroup, { Tab } from '@/components/common/TabGroup' | ||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | import { API_BASE_URL, SITE_TITLE } from '@/config' | ||||
| @@ -23,6 +24,7 @@ export default () => { | |||||
| const [cursor, setCursor] = useState ('') | const [cursor, setCursor] = useState ('') | ||||
| const [loading, setLoading] = useState (false) | const [loading, setLoading] = useState (false) | ||||
| const [posts, setPosts] = useState<Post[]> ([]) | const [posts, setPosts] = useState<Post[]> ([]) | ||||
| const [totalPages, setTotalPages] = useState (0) | |||||
| const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) | const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) | ||||
| const loadMore = async (withCursor: boolean) => { | const loadMore = async (withCursor: boolean) => { | ||||
| @@ -31,15 +33,19 @@ export default () => { | |||||
| const res = await axios.get (`${ API_BASE_URL }/posts`, { | const res = await axios.get (`${ API_BASE_URL }/posts`, { | ||||
| params: { tags: tags.join (' '), | params: { tags: tags.join (' '), | ||||
| match: anyFlg ? 'any' : 'all', | match: anyFlg ? 'any' : 'all', | ||||
| limit: '20', | |||||
| ...(page && { page }), | |||||
| ...(limit && { limit }), | |||||
| ...(withCursor && { cursor }) } }) | ...(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 => ( | setPosts (posts => ( | ||||
| [...((new Map ([...(withCursor ? posts : []), ...data.posts] | [...((new Map ([...(withCursor ? posts : []), ...data.posts] | ||||
| .map (post => [post.id, post]))) | .map (post => [post.id, post]))) | ||||
| .values ())])) | .values ())])) | ||||
| setCursor (data.nextCursor) | setCursor (data.nextCursor) | ||||
| setTotalPages (Math.ceil (data.count / limit)) | |||||
| setLoading (false) | setLoading (false) | ||||
| } | } | ||||
| @@ -49,6 +55,8 @@ export default () => { | |||||
| const tagsQuery = query.get ('tags') ?? '' | const tagsQuery = query.get ('tags') ?? '' | ||||
| const anyFlg = query.get ('match') === 'any' | const anyFlg = query.get ('match') === 'any' | ||||
| const tags = tagsQuery.split (' ').filter (e => e !== '') | const tags = tagsQuery.split (' ').filter (e => e !== '') | ||||
| const page = Number (query.get ('page') ?? 1) | |||||
| const limit = Number (query.get ('limit') ?? 20) | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const observer = new IntersectionObserver (entries => { | const observer = new IntersectionObserver (entries => { | ||||
| @@ -65,7 +73,8 @@ export default () => { | |||||
| }, [loaderRef, loading]) | }, [loaderRef, loading]) | ||||
| useLayoutEffect (() => { | useLayoutEffect (() => { | ||||
| const savedState = sessionStorage.getItem (`posts:${ tagsQuery }`) | |||||
| // TODO: 無限ロード用 | |||||
| const savedState = /* sessionStorage.getItem (`posts:${ tagsQuery }`) */ null | |||||
| if (savedState && navigationType === 'POP') | if (savedState && navigationType === 'POP') | ||||
| { | { | ||||
| const { posts, cursor, scroll } = JSON.parse (savedState) | const { posts, cursor, scroll } = JSON.parse (savedState) | ||||
| @@ -116,18 +125,23 @@ export default () => { | |||||
| <MainArea> | <MainArea> | ||||
| <TabGroup> | <TabGroup> | ||||
| <Tab name="広場"> | <Tab name="広場"> | ||||
| {posts.length | |||||
| {posts.length > 0 | |||||
| ? ( | ? ( | ||||
| <PostList posts={posts} onClick={() => { | |||||
| const statesToSave = { | |||||
| posts, cursor, | |||||
| scroll: containerRef.current?.scrollTop ?? 0 } | |||||
| sessionStorage.setItem (`posts:${ tagsQuery }`, | |||||
| JSON.stringify (statesToSave)) | |||||
| }}/>) | |||||
| <> | |||||
| <PostList posts={posts} onClick={() => { | |||||
| // TODO: 無限ロード用なので復活時に戻す. | |||||
| // const statesToSave = { | |||||
| // posts, cursor, | |||||
| // scroll: containerRef.current?.scrollTop ?? 0 } | |||||
| // sessionStorage.setItem (`posts:${ tagsQuery }`, | |||||
| // JSON.stringify (statesToSave)) | |||||
| }}/> | |||||
| <Pagination page={page} totalPages={totalPages}/> | |||||
| </>) | |||||
| : !(loading) && '広場には何もありませんよ.'} | : !(loading) && '広場には何もありませんよ.'} | ||||
| {loading && 'Loading...'} | {loading && 'Loading...'} | ||||
| <div ref={loaderRef} className="h-12"/> | |||||
| {/* TODO: 無限ローディング復活までコメント・アウト */} | |||||
| {/* <div ref={loaderRef} className="h-12"/> */} | |||||
| </Tab> | </Tab> | ||||
| {tags.length === 1 && ( | {tags.length === 1 && ( | ||||
| <Tab name="Wiki"> | <Tab name="Wiki"> | ||||
| @@ -4,6 +4,7 @@ import { Helmet } from 'react-helmet-async' | |||||
| import { useNavigate } from 'react-router-dom' | import { useNavigate } from 'react-router-dom' | ||||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | import PostFormTagsArea from '@/components/PostFormTagsArea' | ||||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | |||||
| import Form from '@/components/common/Form' | import Form from '@/components/common/Form' | ||||
| import Label from '@/components/common/Label' | import Label from '@/components/common/Label' | ||||
| import PageTitle from '@/components/common/PageTitle' | import PageTitle from '@/components/common/PageTitle' | ||||
| @@ -26,15 +27,17 @@ export default (({ user }: Props) => { | |||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null) | |||||
| const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) | |||||
| const [tags, setTags] = useState ('') | |||||
| const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) | |||||
| const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) | |||||
| const [thumbnailLoading, setThumbnailLoading] = useState (false) | |||||
| const [thumbnailPreview, setThumbnailPreview] = useState<string> ('') | |||||
| const [title, setTitle] = useState ('') | const [title, setTitle] = useState ('') | ||||
| const [titleAutoFlg, setTitleAutoFlg] = useState (true) | const [titleAutoFlg, setTitleAutoFlg] = useState (true) | ||||
| const [titleLoading, setTitleLoading] = useState (false) | const [titleLoading, setTitleLoading] = useState (false) | ||||
| const [url, setURL] = useState ('') | const [url, setURL] = useState ('') | ||||
| const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) | |||||
| const [thumbnailPreview, setThumbnailPreview] = useState<string> ('') | |||||
| const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) | |||||
| const [thumbnailLoading, setThumbnailLoading] = useState (false) | |||||
| const [tags, setTags] = useState ('') | |||||
| const previousURLRef = useRef ('') | const previousURLRef = useRef ('') | ||||
| @@ -45,6 +48,10 @@ export default (({ user }: Props) => { | |||||
| formData.append ('tags', tags) | formData.append ('tags', tags) | ||||
| if (thumbnailFile) | if (thumbnailFile) | ||||
| formData.append ('thumbnail', thumbnailFile) | formData.append ('thumbnail', thumbnailFile) | ||||
| if (originalCreatedFrom) | |||||
| formData.append ('original_created_from', originalCreatedFrom) | |||||
| if (originalCreatedBefore) | |||||
| formData.append ('original_created_before', originalCreatedBefore) | |||||
| try | try | ||||
| { | { | ||||
| @@ -122,7 +129,7 @@ export default (({ user }: Props) => { | |||||
| {/* URL */} | {/* URL */} | ||||
| <div> | <div> | ||||
| <Label>URL</Label> | <Label>URL</Label> | ||||
| <input type="text" | |||||
| <input type="url" | |||||
| placeholder="例:https://www.nicovideo.jp/watch/..." | placeholder="例:https://www.nicovideo.jp/watch/..." | ||||
| value={url} | value={url} | ||||
| onChange={e => setURL (e.target.value)} | onChange={e => setURL (e.target.value)} | ||||
| @@ -181,6 +188,13 @@ export default (({ user }: Props) => { | |||||
| {/* タグ */} | {/* タグ */} | ||||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | <PostFormTagsArea tags={tags} setTags={setTags}/> | ||||
| {/* オリジナルの作成日時 */} | |||||
| <PostOriginalCreatedTimeField | |||||
| originalCreatedFrom={originalCreatedFrom} | |||||
| setOriginalCreatedFrom={setOriginalCreatedFrom} | |||||
| originalCreatedBefore={originalCreatedBefore} | |||||
| setOriginalCreatedBefore={setOriginalCreatedBefore}/> | |||||
| {/* 送信 */} | {/* 送信 */} | ||||
| <Button onClick={handleSubmit} | <Button onClick={handleSubmit} | ||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" | className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" | ||||
| @@ -36,9 +36,16 @@ export default () => { | |||||
| if (/^\d+$/.test (title)) | if (/^\d+$/.test (title)) | ||||
| { | { | ||||
| void (async () => { | 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 | return | ||||
| @@ -51,6 +58,8 @@ export default () => { | |||||
| `${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`, | `${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`, | ||||
| { params: version ? { version } : { } }) | { params: version ? { version } : { } }) | ||||
| const data = toCamel (res.data as any, { deep: true }) as WikiPage | const data = toCamel (res.data as any, { deep: true }) as WikiPage | ||||
| if (data.title !== title) | |||||
| navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | |||||
| setWikiPage (data) | setWikiPage (data) | ||||
| WikiIdBus.set (data.id) | WikiIdBus.set (data.id) | ||||
| } | } | ||||
| @@ -1,7 +1,7 @@ | |||||
| import React from 'react' | |||||
| import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' | import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' | ||||
| import type { ReactNode } from 'react' | |||||
| export type Category = typeof CATEGORIES[number] | export type Category = typeof CATEGORIES[number] | ||||
| export type Menu = MenuItem[] | export type Menu = MenuItem[] | ||||
| @@ -17,28 +17,38 @@ export type NicoTag = Tag & { | |||||
| linkedTags: Tag[] } | linkedTags: Tag[] } | ||||
| export type Post = { | 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 = { | export type Tag = { | ||||
| id: number | id: number | ||||
| name: string | name: string | ||||
| category: Category | category: Category | ||||
| postCount: number } | |||||
| postCount: number | |||||
| children?: Tag[] } | |||||
| export type User = { | export type User = { | ||||
| id: number | id: number | ||||
| @@ -2,35 +2,26 @@ | |||||
| import type { Config } from 'tailwindcss' | import type { Config } from 'tailwindcss' | ||||
| import { DARK_COLOUR_SHADE, | 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) | const colours = Object.values (TAG_COLOUR) | ||||
| export default { | 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 | |||||