| @@ -63,3 +63,5 @@ gem 'diff-lcs' | |||
| gem 'dotenv-rails' | |||
| gem 'whenever', require: false | |||
| gem 'discard' | |||
| @@ -90,6 +90,8 @@ GEM | |||
| crass (1.0.6) | |||
| date (3.4.1) | |||
| diff-lcs (1.6.2) | |||
| discard (1.4.0) | |||
| activerecord (>= 4.2, < 9.0) | |||
| dotenv (3.1.8) | |||
| dotenv-rails (3.1.8) | |||
| dotenv (= 3.1.8) | |||
| @@ -420,6 +422,7 @@ DEPENDENCIES | |||
| bootsnap | |||
| brakeman | |||
| diff-lcs | |||
| discard | |||
| dotenv-rails | |||
| gollum | |||
| image_processing (~> 1.14) | |||
| @@ -1,34 +1,50 @@ | |||
| require 'open-uri' | |||
| require 'nokogiri' | |||
| class PostsController < ApplicationController | |||
| Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) | |||
| # GET /posts | |||
| def index | |||
| limit = params[:limit].presence&.to_i | |||
| page = (params[:page].presence || 1).to_i | |||
| limit = (params[:limit].presence || 20).to_i | |||
| cursor = params[:cursor].presence | |||
| q = filtered_posts.order(created_at: :desc) | |||
| q = q.where('posts.created_at < ?', Time.iso8601(cursor)) if cursor | |||
| page = 1 if page < 1 | |||
| limit = 1 if limit < 1 | |||
| offset = (page - 1) * limit | |||
| posts = limit ? q.limit(limit + 1) : q | |||
| sort_sql = | |||
| 'COALESCE(posts.original_created_before - INTERVAL 1 SECOND,' + | |||
| 'posts.original_created_from,' + | |||
| 'posts.created_at)' | |||
| q = | |||
| filtered_posts | |||
| .preload(:tags) | |||
| .with_attached_thumbnail | |||
| .select("posts.*, #{ sort_sql } AS sort_ts") | |||
| .order(Arel.sql("#{ sort_sql } DESC")) | |||
| posts = ( | |||
| if cursor | |||
| q.where("#{ sort_sql } < ?", Time.iso8601(cursor)).limit(limit + 1) | |||
| else | |||
| q.limit(limit).offset(offset) | |||
| end).to_a | |||
| next_cursor = nil | |||
| if limit && posts.size > limit | |||
| next_cursor = posts.last.created_at.iso8601(6) | |||
| if cursor && posts.length > limit | |||
| next_cursor = posts.last.read_attribute('sort_ts').iso8601(6) | |||
| posts = posts.first(limit) | |||
| end | |||
| render json: { posts: posts.map { |post| | |||
| post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap { |json| | |||
| post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap do |json| | |||
| json['thumbnail'] = | |||
| if post.thumbnail.attached? | |||
| rails_storage_proxy_url(post.thumbnail, only_path: false) | |||
| else | |||
| nil | |||
| end | |||
| } | |||
| }, next_cursor: } | |||
| end | |||
| }, count: filtered_posts.count(:id), next_cursor: } | |||
| end | |||
| def random | |||
| @@ -39,7 +55,7 @@ class PostsController < ApplicationController | |||
| render json: (post | |||
| .as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) | |||
| .merge(viewed: viewed)) | |||
| .merge(viewed:)) | |||
| end | |||
| # GET /posts/1 | |||
| @@ -49,9 +65,12 @@ class PostsController < ApplicationController | |||
| viewed = current_user&.viewed?(post) || false | |||
| render json: (post | |||
| .as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) | |||
| .merge(related: post.related(limit: 20), viewed:)) | |||
| json = post.as_json | |||
| json['tags'] = build_tag_tree_for(post.tags) | |||
| json['related'] = post.related(limit: 20) | |||
| json['viewed'] = viewed | |||
| render json: | |||
| end | |||
| # POST /posts | |||
| @@ -60,18 +79,23 @@ class PostsController < ApplicationController | |||
| return head :forbidden unless current_user.member? | |||
| # TODO: URL が正規のものがチェック,不正ならエラー | |||
| # TODO: title、URL は必須にする. | |||
| # TODO: URL は必須にする(タイトルは省略可). | |||
| # TODO: サイトに応じて thumbnail_base 設定 | |||
| title = params[:title] | |||
| url = params[:url] | |||
| thumbnail = params[:thumbnail] | |||
| tag_names = params[:tags].to_s.split(' ') | |||
| original_created_from = params[:original_created_from] | |||
| original_created_before = params[:original_created_before] | |||
| post = Post.new(title:, url:, thumbnail_base: '', uploaded_user: current_user) | |||
| post = Post.new(title:, url:, thumbnail_base: '', uploaded_user: current_user, | |||
| original_created_from:, original_created_before:) | |||
| post.thumbnail.attach(thumbnail) | |||
| if post.save | |||
| post.resized_thumbnail! | |||
| post.tags = Tag.normalise_tags(tags_names) | |||
| tags = Tag.normalise_tags(tag_names) | |||
| tags = Tag.expand_parent_tags(tags) | |||
| sync_post_tags!(post, tags) | |||
| render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), | |||
| status: :created | |||
| else | |||
| @@ -100,12 +124,18 @@ class PostsController < ApplicationController | |||
| title = params[:title] | |||
| tag_names = params[:tags].to_s.split(' ') | |||
| original_created_from = params[:original_created_from] | |||
| original_created_before = params[:original_created_before] | |||
| post = Post.find(params[:id].to_i) | |||
| tags = post.tags.where(category: 'nico').to_a + Tag.normalise_tags(tag_names) | |||
| if post.update(title:, tags:) | |||
| render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), | |||
| status: :ok | |||
| if post.update(title:, original_created_from:, original_created_before:) | |||
| tags = post.tags.where(category: 'nico').to_a + | |||
| Tag.normalise_tags(tag_names, with_tagme: false) | |||
| tags = Tag.expand_parent_tags(tags) | |||
| sync_post_tags!(post, tags) | |||
| json = post.as_json | |||
| json['tags'] = build_tag_tree_for(post.tags) | |||
| render json:, status: :ok | |||
| else | |||
| render json: post.errors, status: :unprocessable_entity | |||
| end | |||
| @@ -115,12 +145,54 @@ class PostsController < ApplicationController | |||
| def destroy | |||
| end | |||
| def changes | |||
| id = params[:id] | |||
| page = (params[:page].presence || 1).to_i | |||
| limit = (params[:limit].presence || 20).to_i | |||
| page = 1 if page < 1 | |||
| limit = 1 if limit < 1 | |||
| offset = (page - 1) * limit | |||
| pts = PostTag.with_discarded | |||
| pts = pts.where(post_id: id) if id.present? | |||
| pts = pts.includes(:post, :tag, :created_user, :deleted_user) | |||
| events = [] | |||
| pts.each do |pt| | |||
| events << Event.new( | |||
| post: pt.post, | |||
| tag: pt.tag, | |||
| user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name }, | |||
| change_type: 'add', | |||
| timestamp: pt.created_at) | |||
| if pt.discarded_at | |||
| events << Event.new( | |||
| post: pt.post, | |||
| tag: pt.tag, | |||
| user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name }, | |||
| change_type: 'remove', | |||
| timestamp: pt.discarded_at) | |||
| end | |||
| end | |||
| events.sort_by!(&:timestamp) | |||
| events.reverse! | |||
| render json: { changes: events.slice(offset, limit).as_json, count: events.size } | |||
| end | |||
| private | |||
| def filtered_posts | |||
| tag_names = params[:tags]&.split(' ') | |||
| match_type = params[:match] | |||
| tag_names.present? ? filter_posts_by_tags(tag_names, match_type) : Post.all | |||
| if tag_names.present? | |||
| filter_posts_by_tags(tag_names, match_type) | |||
| else | |||
| Post.all | |||
| end | |||
| end | |||
| def filter_posts_by_tags tag_names, match_type | |||
| @@ -134,4 +206,70 @@ class PostsController < ApplicationController | |||
| end | |||
| posts.distinct | |||
| end | |||
| def sync_post_tags! post, desired_tags | |||
| desired_tags.each do |t| | |||
| t.save! if t.new_record? | |||
| end | |||
| desired_ids = desired_tags.map(&:id).to_set | |||
| current_ids = post.tags.pluck(:id).to_set | |||
| to_add = desired_ids - current_ids | |||
| to_remove = current_ids - desired_ids | |||
| Tag.where(id: to_add).find_each do |tag| | |||
| begin | |||
| PostTag.create!(post:, tag:, created_user: current_user) | |||
| rescue ActiveRecord::RecordNotUnique | |||
| ; | |||
| end | |||
| end | |||
| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| | |||
| pt.discard_by!(current_user) | |||
| end | |||
| end | |||
| def build_tag_tree_for tags | |||
| tags = tags.to_a | |||
| tag_ids = tags.map(&:id) | |||
| implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids) | |||
| children_ids_by_parent = Hash.new { |h, k| h[k] = [] } | |||
| implications.each do |imp| | |||
| children_ids_by_parent[imp.parent_tag_id] << imp.tag_id | |||
| end | |||
| child_ids = children_ids_by_parent.values.flatten.uniq | |||
| root_ids = tag_ids - child_ids | |||
| tags_by_id = tags.index_by(&:id) | |||
| memo = { } | |||
| build_node = -> tag_id, path do | |||
| tag = tags_by_id[tag_id] | |||
| return nil unless tag | |||
| if path.include?(tag_id) | |||
| return tag.as_json(only: [:id, :name, :category, :post_count]).merge(children: []) | |||
| end | |||
| if memo.key?(tag_id) | |||
| return memo[tag_id] | |||
| end | |||
| new_path = path + [tag_id] | |||
| child_ids = children_ids_by_parent[tag_id] || [] | |||
| children = child_ids.filter_map { |cid| build_node.(cid, new_path) } | |||
| memo[tag_id] = tag.as_json(only: [:id, :name, :category, :post_count]).merge(children:) | |||
| end | |||
| root_ids.filter_map { |id| build_node.call(id, []) } | |||
| end | |||
| end | |||
| @@ -6,12 +6,15 @@ class UsersController < ApplicationController | |||
| end | |||
| def verify | |||
| ip_bin = IPAddr.new(request.remote_ip).hton | |||
| ip_address = IpAddress.find_or_create_by!(ip_address: ip_bin) | |||
| user = User.find_by(inheritance_code: params[:code]) | |||
| render json: if user | |||
| { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } | |||
| else | |||
| { valid: false } | |||
| end | |||
| return render json: { valid: false } unless user | |||
| UserIp.find_or_create_by!(user:, ip_address:) | |||
| render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } | |||
| end | |||
| def renew | |||
| @@ -13,6 +13,22 @@ class WikiPagesController < ApplicationController | |||
| render_wiki_page_or_404 WikiPage.find_by(title: params[:title]) | |||
| end | |||
| def exists | |||
| if WikiPage.exists?(params[:id]) | |||
| head :no_content | |||
| else | |||
| head :not_found | |||
| end | |||
| end | |||
| def exists_by_title | |||
| if WikiPage.exists?(title: params[:title]) | |||
| head :no_content | |||
| else | |||
| head :not_found | |||
| end | |||
| end | |||
| def diff | |||
| id = params[:id] | |||
| from = params[:from] | |||
| @@ -6,6 +6,7 @@ class NicoTagRelation < ApplicationRecord | |||
| validates :tag_id, presence: true | |||
| validate :nico_tag_must_be_nico | |||
| validate :tag_mustnt_be_nico | |||
| private | |||
| @@ -1,11 +1,13 @@ | |||
| require 'mini_magick' | |||
| class Post < ApplicationRecord | |||
| require 'mini_magick' | |||
| belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id' | |||
| belongs_to :uploaded_user, class_name: 'User', optional: true | |||
| has_many :post_tags, dependent: :destroy | |||
| has_many :tags, through: :post_tags | |||
| has_many :post_tags, dependent: :destroy, inverse_of: :post | |||
| has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post | |||
| has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' | |||
| has_many :tags, through: :active_post_tags | |||
| has_many :user_post_views, dependent: :destroy | |||
| has_many :post_similarities_as_post, | |||
| class_name: 'PostSimilarity', | |||
| @@ -15,6 +17,8 @@ class Post < ApplicationRecord | |||
| foreign_key: :target_post_id | |||
| has_one_attached :thumbnail | |||
| validate :validate_original_created_range | |||
| def as_json options = { } | |||
| super(options).merge({ thumbnail: thumbnail.attached? ? | |||
| Rails.application.routes.url_helpers.rails_blob_url( | |||
| @@ -49,4 +53,20 @@ class Post < ApplicationRecord | |||
| filename: 'resized_thumbnail.jpg', | |||
| content_type: 'image/jpeg') | |||
| end | |||
| private | |||
| def validate_original_created_range | |||
| f = original_created_from | |||
| b = original_created_before | |||
| return if f.blank? || b.blank? | |||
| f = Time.zone.parse(f) if String === f | |||
| b = Time.zone.parse(b) if String === b | |||
| return if !(f) || !(b) | |||
| if f >= b | |||
| errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.' | |||
| end | |||
| end | |||
| end | |||
| @@ -1,7 +1,25 @@ | |||
| class PostTag < ApplicationRecord | |||
| include Discard::Model | |||
| belongs_to :post | |||
| belongs_to :tag, counter_cache: :post_count | |||
| belongs_to :created_user, class_name: 'User', optional: true | |||
| belongs_to :deleted_user, class_name: 'User', optional: true | |||
| validates :post_id, presence: true | |||
| validates :tag_id, presence: true | |||
| validates :post_id, uniqueness: { | |||
| scope: :tag_id, | |||
| conditions: -> { where(discarded_at: nil) } } | |||
| def discard_by! deleted_user | |||
| return self if discarded? | |||
| transaction do | |||
| update!(discarded_at: Time.current, deleted_user:) | |||
| Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)') | |||
| end | |||
| self | |||
| end | |||
| end | |||
| @@ -1,6 +1,8 @@ | |||
| class Tag < ApplicationRecord | |||
| has_many :post_tags, dependent: :destroy | |||
| has_many :posts, through: :post_tags | |||
| has_many :post_tags, dependent: :delete_all, inverse_of: :tag | |||
| has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag | |||
| has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' | |||
| has_many :posts, through: :active_post_tags | |||
| has_many :tag_aliases, dependent: :destroy | |||
| has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy | |||
| @@ -11,6 +13,14 @@ class Tag < ApplicationRecord | |||
| dependent: :destroy | |||
| has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag | |||
| has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy | |||
| has_many :children, through: :tag_implications, source: :tag | |||
| has_many :reversed_tag_implications, class_name: 'TagImplication', | |||
| foreign_key: :tag_id, | |||
| dependent: :destroy | |||
| has_many :parents, through: :reversed_tag_implications, source: :parent_tag | |||
| enum :category, { deerjikist: 'deerjikist', | |||
| meme: 'meme', | |||
| character: 'character', | |||
| @@ -35,13 +45,13 @@ class Tag < ApplicationRecord | |||
| 'meta:' => 'meta' }.freeze | |||
| def self.tagme | |||
| @tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag| | |||
| @tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag| | |||
| tag.category = 'meta' | |||
| end | |||
| end | |||
| def self.bot | |||
| @bot ||= Tag.find_or_initialize_by(name: 'bot操作') do |tag| | |||
| @bot ||= Tag.find_or_create_by!(name: 'bot操作') do |tag| | |||
| tag.category = 'meta' | |||
| end | |||
| end | |||
| @@ -57,10 +67,33 @@ class Tag < ApplicationRecord | |||
| end | |||
| end | |||
| end | |||
| tags << Tag.tagme if with_tagme && tags.size < 20 && tags.none?(Tag.tagme) | |||
| tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) | |||
| tags.uniq | |||
| end | |||
| def self.expand_parent_tags tags | |||
| return [] if tags.blank? | |||
| seen = Set.new | |||
| result = [] | |||
| stack = tags.compact.dup | |||
| until stack.empty? | |||
| tag = stack.pop | |||
| next unless tag | |||
| tag.parents.each do |parent| | |||
| next if seen.include?(parent.id) | |||
| seen << parent.id | |||
| result << parent | |||
| stack << parent | |||
| end | |||
| end | |||
| (result + tags).uniq { |t| t.id } | |||
| end | |||
| private | |||
| def nico_tag_name_must_start_with_nico | |||
| @@ -0,0 +1,17 @@ | |||
| class TagImplication < ApplicationRecord | |||
| belongs_to :tag, class_name: 'Tag' | |||
| belongs_to :parent_tag, class_name: 'Tag' | |||
| validates :tag_id, presence: true, uniqueness: { scope: :parent_tag_id } | |||
| validates :parent_tag_id, presence: true | |||
| validate :parent_tag_mustnt_be_itself | |||
| private | |||
| def parent_tag_mustnt_be_itself | |||
| if parent_tag == tag | |||
| errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.' | |||
| end | |||
| end | |||
| end | |||
| @@ -8,7 +8,6 @@ class User < ApplicationRecord | |||
| has_many :posts | |||
| has_many :settings | |||
| has_many :ip_addresses | |||
| has_many :user_ips, dependent: :destroy | |||
| has_many :ip_addresses, through: :user_ips | |||
| has_many :user_post_views, dependent: :destroy | |||
| @@ -1,45 +1,60 @@ | |||
| Rails.application.routes.draw do | |||
| get 'tags/nico', to: 'nico_tags#index' | |||
| put 'tags/nico/:id', to: 'nico_tags#update' | |||
| get 'tags/autocomplete', to: 'tags#autocomplete' | |||
| get 'tags/name/:name', to: 'tags#show_by_name' | |||
| get 'posts/random', to: 'posts#random' | |||
| post 'posts/:id/viewed', to: 'posts#viewed' | |||
| delete 'posts/:id/viewed', to: 'posts#unviewed' | |||
| get 'preview/title', to: 'preview#title' | |||
| get 'preview/thumbnail', to: 'preview#thumbnail' | |||
| get 'wiki/title/:title', to: 'wiki_pages#show_by_title' | |||
| get 'wiki/search', to: 'wiki_pages#search' | |||
| get 'wiki/changes', to: 'wiki_pages#changes' | |||
| get 'wiki/:id/diff', to: 'wiki_pages#diff' | |||
| get 'wiki/:id', to: 'wiki_pages#show' | |||
| get 'wiki', to: 'wiki_pages#index' | |||
| post 'wiki', to: 'wiki_pages#create' | |||
| put 'wiki/:id', to: 'wiki_pages#update' | |||
| post 'users/code/renew', to: 'users#renew' | |||
| resources :posts | |||
| resources :ip_addresses | |||
| resources :nico_tag_relations | |||
| resources :post_tags | |||
| resources :settings | |||
| resources :tag_aliases | |||
| resources :tags | |||
| resources :user_ips | |||
| resources :user_post_views | |||
| resources :nico_tags, path: 'tags/nico', only: [:index, :update] | |||
| resources :tags do | |||
| collection do | |||
| get :autocomplete | |||
| get 'name/:name', action: :show_by_name | |||
| end | |||
| end | |||
| scope :preview, controller: :preview do | |||
| get :title | |||
| get :thumbnail | |||
| end | |||
| resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do | |||
| collection do | |||
| get :search | |||
| get :changes | |||
| scope :title do | |||
| get ':title/exists', action: :exists_by_title | |||
| get ':title', action: :show_by_title | |||
| end | |||
| end | |||
| member do | |||
| get :exists | |||
| get :diff | |||
| end | |||
| end | |||
| resources :posts do | |||
| collection do | |||
| get :random | |||
| get :changes | |||
| end | |||
| member do | |||
| post :viewed | |||
| delete :viewed, action: :unviewed | |||
| end | |||
| end | |||
| resources :users, only: [:create, :update] do | |||
| collection do | |||
| post :verify | |||
| get :me | |||
| post 'code/renew', action: :renew | |||
| end | |||
| end | |||
| # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html | |||
| # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. | |||
| # Can be used by load balancers and uptime monitors to verify that the app is live. | |||
| # get "up" => "rails/health#show", as: :rails_health_check | |||
| # Defines the root path route ("/") | |||
| # root "posts#index" | |||
| resources :ip_addresses | |||
| resources :nico_tag_relations | |||
| resources :post_tags | |||
| resources :settings | |||
| resources :tag_aliases | |||
| resources :user_ips | |||
| resources :user_post_views | |||
| end | |||
| @@ -6,3 +6,7 @@ set :output, standard: '/var/log/btrc_hub_nico_sync.log', | |||
| every 1.day, at: '3:00 pm' do | |||
| rake 'nico:sync', environment: 'production' | |||
| end | |||
| every 1.day, at: '0:00 am' do | |||
| rake 'post_similarity:calc', environment: 'production' | |||
| end | |||
| @@ -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. | |||
| ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do | |||
| ActiveRecord::Schema[8.0].define(version: 2025_12_10_123200) do | |||
| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.string "name", null: false | |||
| t.string "record_type", null: false | |||
| @@ -40,7 +40,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do | |||
| end | |||
| create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.binary "ip_adress", limit: 16, null: false | |||
| t.binary "ip_address", limit: 16, null: false | |||
| t.boolean "banned", default: false, null: false | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| @@ -70,9 +70,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do | |||
| t.bigint "deleted_user_id" | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| t.datetime "discarded_at" | |||
| t.virtual "is_active", type: :boolean, as: "(`discarded_at` is null)", stored: true | |||
| t.virtual "active_unique_key", type: :string, as: "(case when (`discarded_at` is null) then concat(`post_id`,_utf8mb4':',`tag_id`) else NULL end)", stored: true | |||
| t.index ["active_unique_key"], name: "idx_post_tags_active_unique", unique: true | |||
| t.index ["created_user_id"], name: "index_post_tags_on_created_user_id" | |||
| t.index ["deleted_user_id"], name: "index_post_tags_on_deleted_user_id" | |||
| t.index ["discarded_at"], name: "index_post_tags_on_discarded_at" | |||
| t.index ["post_id", "discarded_at"], name: "index_post_tags_on_post_id_and_discarded_at" | |||
| t.index ["post_id"], name: "index_post_tags_on_post_id" | |||
| t.index ["tag_id", "discarded_at"], name: "index_post_tags_on_tag_id_and_discarded_at" | |||
| t.index ["tag_id"], name: "index_post_tags_on_tag_id" | |||
| end | |||
| @@ -83,6 +90,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do | |||
| t.bigint "parent_id" | |||
| t.bigint "uploaded_user_id" | |||
| t.datetime "created_at", null: false | |||
| t.datetime "original_created_from" | |||
| t.datetime "original_created_before" | |||
| t.datetime "updated_at", null: false | |||
| t.index ["parent_id"], name: "index_posts_on_parent_id" | |||
| t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" | |||
| @@ -105,6 +114,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do | |||
| t.index ["tag_id"], name: "index_tag_aliases_on_tag_id" | |||
| end | |||
| create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "tag_id", null: false | |||
| t.bigint "parent_tag_id", null: false | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| t.index ["parent_tag_id"], name: "index_tag_implications_on_parent_tag_id" | |||
| t.index ["tag_id", "parent_tag_id"], name: "index_tag_implications_on_tag_id_and_parent_tag_id", unique: true | |||
| t.index ["tag_id"], name: "index_tag_implications_on_tag_id" | |||
| end | |||
| create_table "tag_similarities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "tag_id", null: false | |||
| t.bigint "target_tag_id", null: false | |||
| @@ -174,6 +193,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do | |||
| add_foreign_key "posts", "users", column: "uploaded_user_id" | |||
| add_foreign_key "settings", "users" | |||
| add_foreign_key "tag_aliases", "tags" | |||
| add_foreign_key "tag_implications", "tags" | |||
| add_foreign_key "tag_implications", "tags", column: "parent_tag_id" | |||
| add_foreign_key "tag_similarities", "tags" | |||
| add_foreign_key "tag_similarities", "tags", column: "target_tag_id" | |||
| add_foreign_key "user_ips", "ip_addresses" | |||
| @@ -1,12 +0,0 @@ | |||
| namespace :nico do | |||
| desc 'ニコタグ連携' | |||
| task link: :environment do | |||
| Post.find_each do |post| | |||
| tags = post.tags.where(category: 'nico') | |||
| tags.each do |tag| | |||
| post.tags.concat(tag.linked_tags) if tag.linked_tags.present? | |||
| end | |||
| post.tags = post.tags.to_a.uniq | |||
| end | |||
| end | |||
| end | |||
| @@ -5,12 +5,32 @@ namespace :nico do | |||
| require 'open-uri' | |||
| require 'nokogiri' | |||
| fetch_thumbnail = -> url { | |||
| fetch_thumbnail = -> url do | |||
| html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read | |||
| doc = Nokogiri::HTML(html) | |||
| doc.at('meta[name="thumbnail"]')&.[]('content').presence | |||
| } | |||
| end | |||
| def sync_post_tags! post, desired_tag_ids | |||
| desired_ids = desired_tag_ids.compact.to_set | |||
| current_ids = post.tags.pluck(:id).to_set | |||
| to_add = desired_ids - current_ids | |||
| to_remove = current_ids - desired_ids | |||
| Tag.where(id: to_add.to_a).find_each do |tag| | |||
| begin | |||
| PostTag.create!(post:, tag:) | |||
| rescue ActiveRecord::RecordNotUnique | |||
| ; | |||
| end | |||
| end | |||
| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| | |||
| pt.discard_by!(nil) | |||
| end | |||
| end | |||
| mysql_user = ENV['MYSQL_USER'] | |||
| mysql_pass = ENV['MYSQL_PASS'] | |||
| @@ -19,43 +39,57 @@ namespace :nico do | |||
| { 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, | |||
| 'python3', "#{ nizika_nico_path }/get_videos.py") | |||
| if status.success? | |||
| data = JSON.parse(stdout) | |||
| data.each do |datum| | |||
| post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post| | |||
| post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)} | |||
| } | |||
| unless post | |||
| title = datum['title'] | |||
| url = "https://www.nicovideo.jp/watch/#{ datum['code'] }" | |||
| thumbnail_base = fetch_thumbnail.(url) || '' rescue '' | |||
| post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil) | |||
| if thumbnail_base.present? | |||
| post.thumbnail.attach( | |||
| io: URI.open(thumbnail_base), | |||
| filename: File.basename(URI.parse(thumbnail_base).path), | |||
| content_type: 'image/jpeg') | |||
| end | |||
| post.save! | |||
| post.resized_thumbnail! | |||
| abort unless status.success? | |||
| data = JSON.parse(stdout) | |||
| data.each do |datum| | |||
| post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post| | |||
| post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)} | |||
| } | |||
| unless post | |||
| title = datum['title'] | |||
| url = "https://www.nicovideo.jp/watch/#{ datum['code'] }" | |||
| thumbnail_base = fetch_thumbnail.(url) || '' rescue '' | |||
| post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil) | |||
| if thumbnail_base.present? | |||
| post.thumbnail.attach( | |||
| io: URI.open(thumbnail_base), | |||
| filename: File.basename(URI.parse(thumbnail_base).path), | |||
| content_type: 'image/jpeg') | |||
| end | |||
| post.save! | |||
| post.resized_thumbnail! | |||
| sync_post_tags!(post, [Tag.tagme.id]) | |||
| end | |||
| kept_tags = post.tags.reload | |||
| kept_non_nico_ids = kept_tags.where.not(category: 'nico').pluck(:id).to_set | |||
| current_tags = post.tags.where(category: 'nico').pluck(:name).sort | |||
| new_tags = datum['tags'].map { |tag| "nico:#{ tag }" }.sort | |||
| if current_tags != new_tags | |||
| post.tags.destroy(post.tags.where(name: current_tags)) | |||
| tags_to_add = [] | |||
| new_tags.each do |name| | |||
| tag = Tag.find_or_initialize_by(name:) do |t| | |||
| t.category = 'nico' | |||
| end | |||
| tags_to_add.concat([tag] + tag.linked_tags) | |||
| end | |||
| tags_to_add << Tag.tagme if post.tags.size < 20 | |||
| tags_to_add << Tag.bot | |||
| post.tags = (post.tags + tags_to_add).uniq | |||
| desired_nico_ids = [] | |||
| desired_non_nico_ids = [] | |||
| datum['tags'].each do |raw| | |||
| name = "nico:#{ raw }" | |||
| tag = Tag.find_or_initialize_by(name:) do |t| | |||
| t.category = 'nico' | |||
| end | |||
| tag.save! if tag.new_record? | |||
| desired_nico_ids << tag.id | |||
| unless tag.in?(kept_tags) | |||
| desired_non_nico_ids.concat(tag.linked_tags.pluck(:id)) | |||
| desired_nico_ids.concat(tag.linked_tags.pluck(:id)) | |||
| end | |||
| end | |||
| desired_nico_ids.uniq! | |||
| desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids | |||
| desired_non_nico_ids.concat(kept_non_nico_ids.to_a) | |||
| desired_non_nico_ids.uniq! | |||
| if kept_non_nico_ids.to_set != desired_non_nico_ids.to_set | |||
| desired_all_ids << Tag.bot.id | |||
| end | |||
| desired_all_ids.uniq! | |||
| sync_post_tags!(post, desired_all_ids) | |||
| end | |||
| end | |||
| end | |||
| @@ -9,6 +9,7 @@ | |||
| "version": "0.0.0", | |||
| "license": "ISC", | |||
| "dependencies": { | |||
| "@fontsource-variable/noto-sans-jp": "^5.2.9", | |||
| "@radix-ui/react-dialog": "^1.1.14", | |||
| "@radix-ui/react-switch": "^1.2.5", | |||
| "@radix-ui/react-toast": "^1.2.14", | |||
| @@ -16,6 +17,7 @@ | |||
| "camelcase-keys": "^9.1.3", | |||
| "class-variance-authority": "^0.7.1", | |||
| "clsx": "^2.1.1", | |||
| "framer-motion": "^12.23.26", | |||
| "humps": "^2.0.1", | |||
| "lucide-react": "^0.511.0", | |||
| "markdown-it": "^14.1.0", | |||
| @@ -25,6 +27,8 @@ | |||
| "react-markdown": "^10.1.0", | |||
| "react-markdown-editor-lite": "^1.3.4", | |||
| "react-router-dom": "^6.30.0", | |||
| "react-youtube": "^10.1.0", | |||
| "remark-gfm": "^4.0.1", | |||
| "tailwind-merge": "^3.3.0" | |||
| }, | |||
| "devDependencies": { | |||
| @@ -945,6 +949,15 @@ | |||
| "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | |||
| } | |||
| }, | |||
| "node_modules/@fontsource-variable/noto-sans-jp": { | |||
| "version": "5.2.9", | |||
| "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-jp/-/noto-sans-jp-5.2.9.tgz", | |||
| "integrity": "sha512-osPL5f7dvGDjuMuFwDTGPLG37030D8X5zk+3BWea6txAVDFeE/ZIrKW0DY0uSDfRn9+NiKbiFn/2QvZveKXTog==", | |||
| "license": "OFL-1.1", | |||
| "funding": { | |||
| "url": "https://github.com/sponsors/ayuhito" | |||
| } | |||
| }, | |||
| "node_modules/@humanfs/core": { | |||
| "version": "0.19.1", | |||
| "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", | |||
| @@ -3375,7 +3388,6 @@ | |||
| "version": "3.1.3", | |||
| "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", | |||
| "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", | |||
| "dev": true, | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/fast-glob": { | |||
| @@ -3562,6 +3574,33 @@ | |||
| "url": "https://github.com/sponsors/rawify" | |||
| } | |||
| }, | |||
| "node_modules/framer-motion": { | |||
| "version": "12.23.26", | |||
| "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", | |||
| "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "motion-dom": "^12.23.23", | |||
| "motion-utils": "^12.23.6", | |||
| "tslib": "^2.4.0" | |||
| }, | |||
| "peerDependencies": { | |||
| "@emotion/is-prop-valid": "*", | |||
| "react": "^18.0.0 || ^19.0.0", | |||
| "react-dom": "^18.0.0 || ^19.0.0" | |||
| }, | |||
| "peerDependenciesMeta": { | |||
| "@emotion/is-prop-valid": { | |||
| "optional": true | |||
| }, | |||
| "react": { | |||
| "optional": true | |||
| }, | |||
| "react-dom": { | |||
| "optional": true | |||
| } | |||
| } | |||
| }, | |||
| "node_modules/fsevents": { | |||
| "version": "2.3.3", | |||
| "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", | |||
| @@ -4168,6 +4207,12 @@ | |||
| "uc.micro": "^2.0.0" | |||
| } | |||
| }, | |||
| "node_modules/load-script": { | |||
| "version": "1.0.0", | |||
| "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz", | |||
| "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==", | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/locate-path": { | |||
| "version": "6.0.0", | |||
| "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", | |||
| @@ -4261,6 +4306,16 @@ | |||
| "markdown-it": "bin/markdown-it.mjs" | |||
| } | |||
| }, | |||
| "node_modules/markdown-table": { | |||
| "version": "3.0.4", | |||
| "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", | |||
| "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", | |||
| "license": "MIT", | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/wooorm" | |||
| } | |||
| }, | |||
| "node_modules/math-intrinsics": { | |||
| "version": "1.1.0", | |||
| "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", | |||
| @@ -4270,6 +4325,34 @@ | |||
| "node": ">= 0.4" | |||
| } | |||
| }, | |||
| "node_modules/mdast-util-find-and-replace": { | |||
| "version": "3.0.2", | |||
| "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", | |||
| "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@types/mdast": "^4.0.0", | |||
| "escape-string-regexp": "^5.0.0", | |||
| "unist-util-is": "^6.0.0", | |||
| "unist-util-visit-parents": "^6.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { | |||
| "version": "5.0.0", | |||
| "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", | |||
| "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", | |||
| "license": "MIT", | |||
| "engines": { | |||
| "node": ">=12" | |||
| }, | |||
| "funding": { | |||
| "url": "https://github.com/sponsors/sindresorhus" | |||
| } | |||
| }, | |||
| "node_modules/mdast-util-from-markdown": { | |||
| "version": "2.0.2", | |||
| "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", | |||
| @@ -4294,6 +4377,107 @@ | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/mdast-util-gfm": { | |||
| "version": "3.1.0", | |||
| "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", | |||
| "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "mdast-util-from-markdown": "^2.0.0", | |||
| "mdast-util-gfm-autolink-literal": "^2.0.0", | |||
| "mdast-util-gfm-footnote": "^2.0.0", | |||
| "mdast-util-gfm-strikethrough": "^2.0.0", | |||
| "mdast-util-gfm-table": "^2.0.0", | |||
| "mdast-util-gfm-task-list-item": "^2.0.0", | |||
| "mdast-util-to-markdown": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/mdast-util-gfm-autolink-literal": { | |||
| "version": "2.0.1", | |||
| "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", | |||
| "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@types/mdast": "^4.0.0", | |||
| "ccount": "^2.0.0", | |||
| "devlop": "^1.0.0", | |||
| "mdast-util-find-and-replace": "^3.0.0", | |||
| "micromark-util-character": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/mdast-util-gfm-footnote": { | |||
| "version": "2.1.0", | |||
| "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", | |||
| "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@types/mdast": "^4.0.0", | |||
| "devlop": "^1.1.0", | |||
| "mdast-util-from-markdown": "^2.0.0", | |||
| "mdast-util-to-markdown": "^2.0.0", | |||
| "micromark-util-normalize-identifier": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/mdast-util-gfm-strikethrough": { | |||
| "version": "2.0.0", | |||
| "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", | |||
| "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@types/mdast": "^4.0.0", | |||
| "mdast-util-from-markdown": "^2.0.0", | |||
| "mdast-util-to-markdown": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/mdast-util-gfm-table": { | |||
| "version": "2.0.0", | |||
| "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", | |||
| "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@types/mdast": "^4.0.0", | |||
| "devlop": "^1.0.0", | |||
| "markdown-table": "^3.0.0", | |||
| "mdast-util-from-markdown": "^2.0.0", | |||
| "mdast-util-to-markdown": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/mdast-util-gfm-task-list-item": { | |||
| "version": "2.0.0", | |||
| "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", | |||
| "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@types/mdast": "^4.0.0", | |||
| "devlop": "^1.0.0", | |||
| "mdast-util-from-markdown": "^2.0.0", | |||
| "mdast-util-to-markdown": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/mdast-util-mdx-expression": { | |||
| "version": "2.0.1", | |||
| "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", | |||
| @@ -4508,6 +4692,127 @@ | |||
| "micromark-util-types": "^2.0.0" | |||
| } | |||
| }, | |||
| "node_modules/micromark-extension-gfm": { | |||
| "version": "3.0.0", | |||
| "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", | |||
| "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "micromark-extension-gfm-autolink-literal": "^2.0.0", | |||
| "micromark-extension-gfm-footnote": "^2.0.0", | |||
| "micromark-extension-gfm-strikethrough": "^2.0.0", | |||
| "micromark-extension-gfm-table": "^2.0.0", | |||
| "micromark-extension-gfm-tagfilter": "^2.0.0", | |||
| "micromark-extension-gfm-task-list-item": "^2.0.0", | |||
| "micromark-util-combine-extensions": "^2.0.0", | |||
| "micromark-util-types": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/micromark-extension-gfm-autolink-literal": { | |||
| "version": "2.1.0", | |||
| "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", | |||
| "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "micromark-util-character": "^2.0.0", | |||
| "micromark-util-sanitize-uri": "^2.0.0", | |||
| "micromark-util-symbol": "^2.0.0", | |||
| "micromark-util-types": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/micromark-extension-gfm-footnote": { | |||
| "version": "2.1.0", | |||
| "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", | |||
| "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "devlop": "^1.0.0", | |||
| "micromark-core-commonmark": "^2.0.0", | |||
| "micromark-factory-space": "^2.0.0", | |||
| "micromark-util-character": "^2.0.0", | |||
| "micromark-util-normalize-identifier": "^2.0.0", | |||
| "micromark-util-sanitize-uri": "^2.0.0", | |||
| "micromark-util-symbol": "^2.0.0", | |||
| "micromark-util-types": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/micromark-extension-gfm-strikethrough": { | |||
| "version": "2.1.0", | |||
| "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", | |||
| "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "devlop": "^1.0.0", | |||
| "micromark-util-chunked": "^2.0.0", | |||
| "micromark-util-classify-character": "^2.0.0", | |||
| "micromark-util-resolve-all": "^2.0.0", | |||
| "micromark-util-symbol": "^2.0.0", | |||
| "micromark-util-types": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/micromark-extension-gfm-table": { | |||
| "version": "2.1.1", | |||
| "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", | |||
| "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "devlop": "^1.0.0", | |||
| "micromark-factory-space": "^2.0.0", | |||
| "micromark-util-character": "^2.0.0", | |||
| "micromark-util-symbol": "^2.0.0", | |||
| "micromark-util-types": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/micromark-extension-gfm-tagfilter": { | |||
| "version": "2.0.0", | |||
| "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", | |||
| "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "micromark-util-types": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/micromark-extension-gfm-task-list-item": { | |||
| "version": "2.1.0", | |||
| "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", | |||
| "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "devlop": "^1.0.0", | |||
| "micromark-factory-space": "^2.0.0", | |||
| "micromark-util-character": "^2.0.0", | |||
| "micromark-util-symbol": "^2.0.0", | |||
| "micromark-util-types": "^2.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/micromark-factory-destination": { | |||
| "version": "2.0.1", | |||
| "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", | |||
| @@ -4939,6 +5244,21 @@ | |||
| "node": ">=16 || 14 >=14.17" | |||
| } | |||
| }, | |||
| "node_modules/motion-dom": { | |||
| "version": "12.23.23", | |||
| "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", | |||
| "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "motion-utils": "^12.23.6" | |||
| } | |||
| }, | |||
| "node_modules/motion-utils": { | |||
| "version": "12.23.6", | |||
| "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", | |||
| "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/ms": { | |||
| "version": "2.1.3", | |||
| "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | |||
| @@ -5014,7 +5334,6 @@ | |||
| "version": "4.1.1", | |||
| "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", | |||
| "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", | |||
| "dev": true, | |||
| "license": "MIT", | |||
| "engines": { | |||
| "node": ">=0.10.0" | |||
| @@ -5389,6 +5708,17 @@ | |||
| "node": ">= 0.8.0" | |||
| } | |||
| }, | |||
| "node_modules/prop-types": { | |||
| "version": "15.8.1", | |||
| "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", | |||
| "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "loose-envify": "^1.4.0", | |||
| "object-assign": "^4.1.1", | |||
| "react-is": "^16.13.1" | |||
| } | |||
| }, | |||
| "node_modules/property-information": { | |||
| "version": "7.1.0", | |||
| "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", | |||
| @@ -5498,6 +5828,12 @@ | |||
| "react": "^16.6.0 || ^17.0.0 || ^18.0.0" | |||
| } | |||
| }, | |||
| "node_modules/react-is": { | |||
| "version": "16.13.1", | |||
| "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", | |||
| "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/react-markdown": { | |||
| "version": "10.1.0", | |||
| "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", | |||
| @@ -5651,6 +5987,23 @@ | |||
| } | |||
| } | |||
| }, | |||
| "node_modules/react-youtube": { | |||
| "version": "10.1.0", | |||
| "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-10.1.0.tgz", | |||
| "integrity": "sha512-ZfGtcVpk0SSZtWCSTYOQKhfx5/1cfyEW1JN/mugGNfAxT3rmVJeMbGpA9+e78yG21ls5nc/5uZJETE3cm3knBg==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "fast-deep-equal": "3.1.3", | |||
| "prop-types": "15.8.1", | |||
| "youtube-player": "5.5.2" | |||
| }, | |||
| "engines": { | |||
| "node": ">= 14.x" | |||
| }, | |||
| "peerDependencies": { | |||
| "react": ">=0.14.1" | |||
| } | |||
| }, | |||
| "node_modules/read-cache": { | |||
| "version": "1.0.0", | |||
| "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", | |||
| @@ -5674,6 +6027,24 @@ | |||
| "node": ">=8.10.0" | |||
| } | |||
| }, | |||
| "node_modules/remark-gfm": { | |||
| "version": "4.0.1", | |||
| "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", | |||
| "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@types/mdast": "^4.0.0", | |||
| "mdast-util-gfm": "^3.0.0", | |||
| "micromark-extension-gfm": "^3.0.0", | |||
| "remark-parse": "^11.0.0", | |||
| "remark-stringify": "^11.0.0", | |||
| "unified": "^11.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/remark-parse": { | |||
| "version": "11.0.0", | |||
| "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", | |||
| @@ -5707,6 +6078,21 @@ | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/remark-stringify": { | |||
| "version": "11.0.0", | |||
| "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", | |||
| "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@types/mdast": "^4.0.0", | |||
| "mdast-util-to-markdown": "^2.0.0", | |||
| "unified": "^11.0.0" | |||
| }, | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/unified" | |||
| } | |||
| }, | |||
| "node_modules/resolve": { | |||
| "version": "1.22.10", | |||
| "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", | |||
| @@ -5871,6 +6257,12 @@ | |||
| "url": "https://github.com/sponsors/isaacs" | |||
| } | |||
| }, | |||
| "node_modules/sister": { | |||
| "version": "3.0.2", | |||
| "resolved": "https://registry.npmjs.org/sister/-/sister-3.0.2.tgz", | |||
| "integrity": "sha512-p19rtTs+NksBRKW9qn0UhZ8/TUI9BPw9lmtHny+Y3TinWlOa9jWh9xB0AtPSdmOy49NJJJSSe0Ey4C7h0TrcYA==", | |||
| "license": "BSD-3-Clause" | |||
| }, | |||
| "node_modules/source-map-js": { | |||
| "version": "1.2.1", | |||
| "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", | |||
| @@ -6811,6 +7203,32 @@ | |||
| "url": "https://github.com/sponsors/sindresorhus" | |||
| } | |||
| }, | |||
| "node_modules/youtube-player": { | |||
| "version": "5.5.2", | |||
| "resolved": "https://registry.npmjs.org/youtube-player/-/youtube-player-5.5.2.tgz", | |||
| "integrity": "sha512-ZGtsemSpXnDky2AUYWgxjaopgB+shFHgXVpiJFeNB5nWEugpW1KWYDaHKuLqh2b67r24GtP6HoSW5swvf0fFIQ==", | |||
| "license": "BSD-3-Clause", | |||
| "dependencies": { | |||
| "debug": "^2.6.6", | |||
| "load-script": "^1.0.0", | |||
| "sister": "^3.0.0" | |||
| } | |||
| }, | |||
| "node_modules/youtube-player/node_modules/debug": { | |||
| "version": "2.6.9", | |||
| "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", | |||
| "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "ms": "2.0.0" | |||
| } | |||
| }, | |||
| "node_modules/youtube-player/node_modules/ms": { | |||
| "version": "2.0.0", | |||
| "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", | |||
| "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/zwitch": { | |||
| "version": "2.0.4", | |||
| "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", | |||
| @@ -11,6 +11,7 @@ | |||
| "preview": "vite preview" | |||
| }, | |||
| "dependencies": { | |||
| "@fontsource-variable/noto-sans-jp": "^5.2.9", | |||
| "@radix-ui/react-dialog": "^1.1.14", | |||
| "@radix-ui/react-switch": "^1.2.5", | |||
| "@radix-ui/react-toast": "^1.2.14", | |||
| @@ -18,6 +19,7 @@ | |||
| "camelcase-keys": "^9.1.3", | |||
| "class-variance-authority": "^0.7.1", | |||
| "clsx": "^2.1.1", | |||
| "framer-motion": "^12.23.26", | |||
| "humps": "^2.0.1", | |||
| "lucide-react": "^0.511.0", | |||
| "markdown-it": "^14.1.0", | |||
| @@ -27,6 +29,8 @@ | |||
| "react-markdown": "^10.1.0", | |||
| "react-markdown-editor-lite": "^1.3.4", | |||
| "react-router-dom": "^6.30.0", | |||
| "react-youtube": "^10.1.0", | |||
| "remark-gfm": "^4.0.1", | |||
| "tailwind-merge": "^3.3.0" | |||
| }, | |||
| "devDependencies": { | |||
| @@ -19,7 +19,6 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`, | |||
| { params: { ...(tagName && { tags: tagName, | |||
| match: 'all', | |||
| limit: '20' }) } })).data.posts | |||
| const fetchPostIds = async () => (await fetchPosts ()).map (post => post.id) | |||
| const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data | |||
| const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name) | |||
| @@ -33,7 +32,7 @@ const createPostListOutlet = async tagName => ` | |||
| <div class="flex gap-4"><a href="#" class="font-bold">広場</a></div> | |||
| <div class="mt-2"> | |||
| <div class="flex flex-wrap gap-6 p-4"> | |||
| ${ (await fetchPosts (tagName)).map (post => ` | |||
| ${ (await fetchPosts (tagName)).slice (0, 20).map (post => ` | |||
| <a class="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg" | |||
| href="/posts/${ post.id }"> | |||
| <img alt="${ post.title }" | |||
| @@ -42,7 +41,7 @@ const createPostListOutlet = async tagName => ` | |||
| fetchpriority="high" | |||
| decoding="async" | |||
| class="object-none w-full h-full" | |||
| src="${ post.url }" /> | |||
| src="${ post.thumbnail }" /> | |||
| </a>`).join ('') } | |||
| </div> | |||
| </div> | |||
| @@ -9,6 +9,7 @@ import { API_BASE_URL } from '@/config' | |||
| import NicoTagListPage from '@/pages/tags/NicoTagListPage' | |||
| import NotFound from '@/pages/NotFound' | |||
| import PostDetailPage from '@/pages/posts/PostDetailPage' | |||
| import PostHistoryPage from '@/pages/posts/PostHistoryPage' | |||
| import PostListPage from '@/pages/posts/PostListPage' | |||
| import PostNewPage from '@/pages/posts/PostNewPage' | |||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | |||
| @@ -20,10 +21,12 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage' | |||
| import WikiNewPage from '@/pages/wiki/WikiNewPage' | |||
| import WikiSearchPage from '@/pages/wiki/WikiSearchPage' | |||
| import type { FC } from 'react' | |||
| import type { User } from '@/types' | |||
| export default () => { | |||
| export default (() => { | |||
| const [user, setUser] = useState<User | null> (null) | |||
| const [status, setStatus] = useState (200) | |||
| @@ -65,30 +68,31 @@ export default () => { | |||
| switch (status) | |||
| { | |||
| case 503: | |||
| return <ServiceUnavailable /> | |||
| return <ServiceUnavailable/> | |||
| } | |||
| return ( | |||
| <BrowserRouter> | |||
| <div className="flex flex-col h-screen w-screen"> | |||
| <TopNav user={user} /> | |||
| <TopNav user={user}/> | |||
| <Routes> | |||
| <Route path="/" element={<Navigate to="/posts" replace />} /> | |||
| <Route path="/posts" element={<PostListPage />} /> | |||
| <Route path="/posts/new" element={<PostNewPage user={user} />} /> | |||
| <Route path="/posts/:id" element={<PostDetailPage user={user} />} /> | |||
| <Route path="/tags/nico" element={<NicoTagListPage user={user} />} /> | |||
| <Route path="/wiki" element={<WikiSearchPage />} /> | |||
| <Route path="/wiki/:title" element={<WikiDetailPage />} /> | |||
| <Route path="/wiki/new" element={<WikiNewPage user={user} />} /> | |||
| <Route path="/wiki/:id/edit" element={<WikiEditPage user={user} />} /> | |||
| <Route path="/wiki/:id/diff" element={<WikiDiffPage />} /> | |||
| <Route path="/wiki/changes" element={<WikiHistoryPage />} /> | |||
| <Route path="/users/settings" element={<SettingPage user={user} setUser={setUser} />} /> | |||
| <Route path="/settings" element={<Navigate to="/users/settings" replace />} /> | |||
| <Route path="*" element={<NotFound />} /> | |||
| <Route path="/" element={<Navigate to="/posts" replace/>}/> | |||
| <Route path="/posts" element={<PostListPage/>}/> | |||
| <Route path="/posts/new" element={<PostNewPage user={user}/>}/> | |||
| <Route path="/posts/:id" element={<PostDetailPage user={user}/>}/> | |||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | |||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | |||
| <Route path="/wiki" element={<WikiSearchPage/>}/> | |||
| <Route path="/wiki/:title" element={<WikiDetailPage/>}/> | |||
| <Route path="/wiki/new" element={<WikiNewPage user={user}/>}/> | |||
| <Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/> | |||
| <Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/> | |||
| <Route path="/wiki/changes" element={<WikiHistoryPage/>}/> | |||
| <Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/> | |||
| <Route path="/settings" element={<Navigate to="/users/settings" replace/>}/> | |||
| <Route path="*" element={<NotFound/>}/> | |||
| </Routes> | |||
| </div> | |||
| <Toaster /> | |||
| <Toaster/> | |||
| </BrowserRouter>) | |||
| } | |||
| }) satisfies FC | |||
| @@ -5,10 +5,12 @@ import errorImg from '@/assets/images/not-found.gif' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { SITE_TITLE } from '@/config' | |||
| import type { FC } from 'react' | |||
| type Props = { status: number } | |||
| export default ({ status }: Props) => { | |||
| export default (({ status }: Props) => { | |||
| const [message, rightMsg, leftMsg]: [string, string, string] = (() => { | |||
| switch (status) | |||
| { | |||
| @@ -39,7 +41,7 @@ export default ({ status }: Props) => { | |||
| return ( | |||
| <MainArea> | |||
| <Helmet> | |||
| <meta name="robots" content="noindex" /> | |||
| <meta name="robots" content="noindex"/> | |||
| <title>{title} | {SITE_TITLE}</title> | |||
| </Helmet> | |||
| <div className="text-6xl font-bold text-transparent | |||
| @@ -50,10 +52,10 @@ export default ({ status }: Props) => { | |||
| <p>{status}</p> | |||
| <div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4"> | |||
| <p style={{ writingMode: 'vertical-rl' }}>{leftMsg}</p> | |||
| <img className="max-w-[70vw]" src={errorImg} alt="逃げたギター" /> | |||
| <img className="max-w-[70vw]" src={errorImg} alt="逃げたギター"/> | |||
| <p style={{ writingMode: 'vertical-rl' }}>{rightMsg}</p> | |||
| </div> | |||
| <p className="mr-[-.5em]">{message}</p> | |||
| </div> | |||
| </MainArea>) | |||
| } | |||
| }) satisfies FC<Props> | |||
| @@ -1,6 +1,9 @@ | |||
| export default () => ( | |||
| import type { FC } from 'react' | |||
| export default (() => ( | |||
| <> | |||
| <span className="hidden md:inline flex items-center px-2">|</span> | |||
| <hr className="block md:hidden w-full opacity-25 | |||
| border-t border-black dark:border-white" /> | |||
| </>) | |||
| border-t border-black dark:border-white"/> | |||
| </>)) satisfies FC | |||
| @@ -4,10 +4,10 @@ type Props = { id: string, | |||
| height: number, | |||
| style?: CSSProperties } | |||
| import type { CSSProperties } from 'react' | |||
| import type { CSSProperties, FC } from 'react' | |||
| export default (props: Props) => { | |||
| export default ((props: Props) => { | |||
| const { id, width, height, style = { } } = props | |||
| const iframeRef = useRef<HTMLIFrameElement> (null) | |||
| @@ -107,5 +107,5 @@ export default (props: Props) => { | |||
| height={height} | |||
| style={margedStyle} | |||
| allowFullScreen | |||
| allow="autoplay" />) | |||
| } | |||
| allow="autoplay"/>) | |||
| }) satisfies FC<Props> | |||
| @@ -1,53 +1,85 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import { useState } from 'react' | |||
| import { useEffect, useState } from 'react' | |||
| import TextArea from '@/components/common/TextArea' | |||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | |||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | |||
| import Label from '@/components/common/Label' | |||
| import { Button } from '@/components/ui/button' | |||
| import { API_BASE_URL } from '@/config' | |||
| import type { Post } from '@/types' | |||
| import type { FC } from 'react' | |||
| type Props = { post: Post | |||
| onSave: (newPost: Post) => void } | |||
| import type { Post, Tag } from '@/types' | |||
| export default ({ post, onSave }: Props) => { | |||
| const tagsToStr = (tags: Tag[]): string => { | |||
| const result: Tag[] = [] | |||
| const walk = (tag: Tag) => { | |||
| const { children, ...rest } = tag | |||
| result.push (rest) | |||
| children?.forEach (walk) | |||
| } | |||
| tags.filter (t => t.category !== 'nico').forEach (walk) | |||
| return [...(new Set (result.map (t => t.name)))].join (' ') | |||
| } | |||
| type Props = { post: Post | |||
| onSave: (newPost: Post) => void } | |||
| export default (({ post, onSave }: Props) => { | |||
| const [originalCreatedBefore, setOriginalCreatedBefore] = | |||
| useState<string | null> (post.originalCreatedBefore) | |||
| const [originalCreatedFrom, setOriginalCreatedFrom] = | |||
| useState<string | null> (post.originalCreatedFrom) | |||
| const [title, setTitle] = useState (post.title) | |||
| const [tags, setTags] = useState<string> (post.tags | |||
| .filter (t => t.category !== 'nico') | |||
| .map (t => t.name) | |||
| .join (' ')) | |||
| const [tags, setTags] = useState<string> ('') | |||
| const handleSubmit = async () => { | |||
| const res = await axios.put (`${ API_BASE_URL }/posts/${ post.id }`, { title, tags }, | |||
| const res = await axios.put ( | |||
| `${ API_BASE_URL }/posts/${ post.id }`, | |||
| { title, tags, | |||
| original_created_from: originalCreatedFrom, | |||
| original_created_before: originalCreatedBefore }, | |||
| { headers: { 'Content-Type': 'multipart/form-data', | |||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } } ) | |||
| 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||
| const data = toCamel (res.data as any, { deep: true }) as Post | |||
| onSave ({ ...post, | |||
| title: data.title, | |||
| tags: data.tags } as Post) | |||
| title: data.title, | |||
| tags: data.tags, | |||
| originalCreatedFrom: data.originalCreatedFrom, | |||
| originalCreatedBefore: data.originalCreatedBefore } as Post) | |||
| } | |||
| useEffect (() => { | |||
| setTags(tagsToStr (post.tags)) | |||
| }, [post]) | |||
| return ( | |||
| <div className="max-w-xl pt-2 space-y-4"> | |||
| {/* タイトル */} | |||
| <div> | |||
| <div className="flex gap-2 mb-1"> | |||
| <label className="flex-1 block font-semibold">タイトル</label> | |||
| </div> | |||
| <Label>タイトル</Label> | |||
| <input type="text" | |||
| className="w-full border rounded p-2" | |||
| value={title} | |||
| onChange={e => setTitle (e.target.value)} /> | |||
| onChange={ev => setTitle (ev.target.value)}/> | |||
| </div> | |||
| {/* タグ */} | |||
| <div> | |||
| <label className="block font-semibold">タグ</label> | |||
| <TextArea value={tags} | |||
| onChange={ev => setTags (ev.target.value)} /> | |||
| </div> | |||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | |||
| {/* オリジナルの作成日時 */} | |||
| <PostOriginalCreatedTimeField | |||
| originalCreatedFrom={originalCreatedFrom} | |||
| setOriginalCreatedFrom={setOriginalCreatedFrom} | |||
| originalCreatedBefore={originalCreatedBefore} | |||
| setOriginalCreatedBefore={setOriginalCreatedBefore}/> | |||
| {/* 送信 */} | |||
| <Button onClick={handleSubmit} | |||
| @@ -55,4 +87,4 @@ export default ({ post, onSave }: Props) => { | |||
| 更新 | |||
| </Button> | |||
| </div>) | |||
| } | |||
| }) satisfies FC<Props> | |||
| @@ -0,0 +1,48 @@ | |||
| import YoutubeEmbed from 'react-youtube' | |||
| import NicoViewer from '@/components/NicoViewer' | |||
| import TwitterEmbed from '@/components/TwitterEmbed' | |||
| import type { FC } from 'react' | |||
| import type { Post } from '@/types' | |||
| type Props = { post: Post } | |||
| export default (({ post }: Props) => { | |||
| const url = new URL (post.url) | |||
| switch (url.hostname.split ('.').slice (-2).join ('.')) | |||
| { | |||
| case 'nicovideo.jp': | |||
| { | |||
| const [videoId] = url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)! | |||
| return <NicoViewer id={videoId} width={640} height={360}/> | |||
| } | |||
| case 'twitter.com': | |||
| case 'x.com': | |||
| const [userId] = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/)! | |||
| const [statusId] = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)! | |||
| return <TwitterEmbed userId={userId} statusId={statusId}/> | |||
| case 'youtube.com': | |||
| { | |||
| const videoId = url.searchParams.get ('v')! | |||
| return ( | |||
| <YoutubeEmbed videoId={videoId} opts={{ playerVars: { | |||
| playsinline: 1, | |||
| autoplay: 1, | |||
| mute: 0, | |||
| loop: 1, | |||
| width: '640', | |||
| height: '360' } }}/>) | |||
| } | |||
| } | |||
| return ( | |||
| <a href={post.url} target="_blank"> | |||
| <img src={post.thumbnailBase || post.thumbnail} | |||
| alt={post.url} | |||
| className="mb-4 w-full"/> | |||
| </a>) | |||
| }) satisfies FC<Props> | |||
| @@ -0,0 +1,84 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import { useRef, useState } from 'react' | |||
| import TagSearchBox from '@/components/TagSearchBox' | |||
| import Label from '@/components/common/Label' | |||
| import TextArea from '@/components/common/TextArea' | |||
| import { API_BASE_URL } from '@/config' | |||
| import type { FC, SyntheticEvent } from 'react' | |||
| import type { Tag } from '@/types' | |||
| const SEP = /\s/ | |||
| const getTokenAt = (value: string, pos: number) => { | |||
| let start = pos | |||
| while (start > 0 && !(SEP.test (value[start - 1]))) | |||
| --start | |||
| let end = pos | |||
| while (end < value.length && !(SEP.test (value[end]))) | |||
| ++end | |||
| return { start, end, token: value.slice (start, end) } | |||
| } | |||
| const replaceToken = (value: string, start: number, end: number, text: string) => ( | |||
| `${ value.slice (0, start) }${ text }${ value.slice (end) }`) | |||
| type Props = { | |||
| tags: string | |||
| setTags: (tags: string) => void } | |||
| export default (({ tags, setTags }: Props) => { | |||
| const ref = useRef<HTMLTextAreaElement> (null) | |||
| const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | |||
| const [suggestions, setSuggestions] = useState<Tag[]> ([]) | |||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | |||
| const handleTagSelect = (tag: Tag) => { | |||
| setSuggestionsVsbl (false) | |||
| const textarea = ref.current! | |||
| const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name) | |||
| setTags (newValue) | |||
| requestAnimationFrame (async () => { | |||
| const p = bounds.start + tag.name.length | |||
| textarea.selectionStart = textarea.selectionEnd = p | |||
| textarea.focus () | |||
| await recompute (p, newValue) | |||
| }) | |||
| } | |||
| const recompute = async (pos: number, v: string = tags) => { | |||
| const { start, end, token } = getTokenAt (v, pos) | |||
| setBounds ({ start, end }) | |||
| const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q: token } }) | |||
| setSuggestions (toCamel (res.data as any, { deep: true }) as Tag[]) | |||
| setSuggestionsVsbl (suggestions.length > 0) | |||
| } | |||
| return ( | |||
| <div> | |||
| <Label>タグ</Label> | |||
| <TextArea | |||
| ref={ref} | |||
| value={tags} | |||
| onChange={ev => setTags (ev.target.value)} | |||
| onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => { | |||
| const pos = (ev.target as HTMLTextAreaElement).selectionStart | |||
| await recompute (pos) | |||
| }}/> | |||
| <TagSearchBox suggestions={suggestionsVsbl && suggestions.length | |||
| ? suggestions | |||
| : [] as Tag[]} | |||
| activeIndex={-1} | |||
| onSelect={handleTagSelect}/> | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| @@ -1,25 +1,25 @@ | |||
| import { Link } from 'react-router-dom' | |||
| import type { MouseEvent } from 'react' | |||
| import type { FC, MouseEvent } from 'react' | |||
| import type { Post } from '@/types' | |||
| type Props = { posts: Post[] | |||
| onClick?: (event: MouseEvent<HTMLElement>) => void } | |||
| export default ({ posts, onClick }: Props) => ( | |||
| export default (({ posts, onClick }: Props) => ( | |||
| <div className="flex flex-wrap gap-6 p-4"> | |||
| {posts.map ((post, i) => ( | |||
| <Link to={`/posts/${ post.id }`} | |||
| key={i} | |||
| key={post.id} | |||
| className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg" | |||
| onClick={onClick}> | |||
| <img src={post.thumbnail || post.thumbnailBase || undefined} | |||
| alt={post.title || post.url} | |||
| title={post.title || post.url || undefined} | |||
| loading="eager" | |||
| fetchPriority="high" | |||
| loading={i < 12 ? 'eager' : 'lazy'} | |||
| decoding="async" | |||
| className="object-none w-full h-full" /> | |||
| className="object-cover w-full h-full"/> | |||
| </Link>))} | |||
| </div>) | |||
| </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,19 +1,49 @@ | |||
| import { AnimatePresence, motion } from 'framer-motion' | |||
| import { useEffect, useState } from 'react' | |||
| import { Link } from 'react-router-dom' | |||
| import TagLink from '@/components/TagLink' | |||
| import TagSearch from '@/components/TagSearch' | |||
| import SectionTitle from '@/components/common/SectionTitle' | |||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | |||
| import SidebarComponent from '@/components/layout/SidebarComponent' | |||
| import { CATEGORIES } from '@/consts' | |||
| import type { FC, ReactNode } from 'react' | |||
| import type { Category, Post, Tag } from '@/types' | |||
| type TagByCategory = { [key in Category]: Tag[] } | |||
| const renderTagTree = ( | |||
| tag: Tag, | |||
| nestLevel: number, | |||
| path: string, | |||
| ): ReactNode[] => { | |||
| const key = `${ path }-${ tag.id }` | |||
| const self = ( | |||
| <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 } | |||
| export default ({ post }: Props) => { | |||
| export default (({ post }: Props) => { | |||
| const [tags, setTags] = useState ({ } as TagByCategory) | |||
| const categoryNames: Record<Category, string> = { | |||
| @@ -46,16 +76,64 @@ export default ({ post }: Props) => { | |||
| return ( | |||
| <SidebarComponent> | |||
| <TagSearch /> | |||
| {CATEGORIES.map ((cat: Category) => cat in tags && ( | |||
| <div className="my-3" key={cat}> | |||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||
| <ul> | |||
| {tags[cat].map ((tag, i) => ( | |||
| <li key={i} className="mb-1"> | |||
| <TagLink tag={tag} /> | |||
| </li>))} | |||
| </ul> | |||
| </div>))} | |||
| <TagSearch/> | |||
| <motion.div key={post?.id ?? 0} layout> | |||
| {CATEGORIES.map ((cat: Category) => cat in tags && ( | |||
| <motion.div layout className="my-3" key={cat}> | |||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||
| <motion.ul layout> | |||
| <AnimatePresence initial={false}> | |||
| {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))} | |||
| </AnimatePresence> | |||
| </motion.ul> | |||
| </motion.div>))} | |||
| {post && ( | |||
| <div> | |||
| <SectionTitle>情報</SectionTitle> | |||
| <ul> | |||
| <li>Id.: {post.id}</li> | |||
| {/* TODO: uploadedUser の取得を対応したらコメント外す */} | |||
| {/* | |||
| <li> | |||
| <>耕作者: </> | |||
| {post.uploadedUser | |||
| ? ( | |||
| <Link to={`/users/${ post.uploadedUser.id }`}> | |||
| {post.uploadedUser.name || '名もなきニジラー'} | |||
| </Link>) | |||
| : 'bot操作'} | |||
| </li> | |||
| */} | |||
| <li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li> | |||
| <li> | |||
| <>リンク: </> | |||
| <a | |||
| className="break-all" | |||
| href={post.url} | |||
| target="_blank" | |||
| rel="noopener noreferrer nofollow"> | |||
| {post.url} | |||
| </a> | |||
| </li> | |||
| <li> | |||
| {/* TODO: 表示形式きしょすぎるので何とかする */} | |||
| <>オリジナルの投稿日時: </> | |||
| {!(post.originalCreatedFrom) && !(post.originalCreatedBefore) | |||
| ? '不明' | |||
| : ( | |||
| <> | |||
| {post.originalCreatedFrom | |||
| && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} | |||
| {post.originalCreatedBefore | |||
| && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} | |||
| </>)} | |||
| </li> | |||
| <li> | |||
| <Link to={`/posts/changes?id=${ post.id }`}>履歴</Link> | |||
| </li> | |||
| </ul> | |||
| </div>)} | |||
| </motion.div> | |||
| </SidebarComponent>) | |||
| } | |||
| }) satisfies FC<Props> | |||
| @@ -1,13 +1,17 @@ | |||
| import axios from 'axios' | |||
| import { useEffect, useState } from 'react' | |||
| import { Link } from 'react-router-dom' | |||
| import { API_BASE_URL } from '@/config' | |||
| import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' | |||
| import { cn } from '@/lib/utils' | |||
| import type { ComponentProps, HTMLAttributes } from 'react' | |||
| import type { ComponentProps, FC, HTMLAttributes } from 'react' | |||
| import type { Tag } from '@/types' | |||
| type CommonProps = { tag: Tag | |||
| nestLevel?: number | |||
| withWiki?: boolean | |||
| withCount?: boolean } | |||
| @@ -20,11 +24,33 @@ type PropsWithoutLink = | |||
| type Props = PropsWithLink | PropsWithoutLink | |||
| export default ({ tag, | |||
| linkFlg = true, | |||
| withWiki = true, | |||
| withCount = true, | |||
| ...props }: Props) => { | |||
| export default (({ tag, | |||
| nestLevel = 0, | |||
| linkFlg = true, | |||
| withWiki = true, | |||
| withCount = true, | |||
| ...props }: Props) => { | |||
| const [havingWiki, setHavingWiki] = useState (true) | |||
| const wikiExists = async (tagName: string) => { | |||
| try | |||
| { | |||
| await axios.get (`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (tagName) }/exists`) | |||
| setHavingWiki (true) | |||
| } | |||
| catch | |||
| { | |||
| setHavingWiki (false) | |||
| } | |||
| } | |||
| useEffect (() => { | |||
| if (!(linkFlg) || !(withWiki)) | |||
| return | |||
| wikiExists (tag.name) | |||
| }, [tag.name, linkFlg, withWiki]) | |||
| const spanClass = cn ( | |||
| `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, | |||
| `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) | |||
| @@ -37,10 +63,25 @@ export default ({ tag, | |||
| <> | |||
| {(linkFlg && withWiki) && ( | |||
| <span className="mr-1"> | |||
| <Link to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||
| className={linkClass}> | |||
| ? | |||
| </Link> | |||
| {havingWiki | |||
| ? ( | |||
| <Link to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||
| className={linkClass}> | |||
| ? | |||
| </Link>) | |||
| : ( | |||
| <Link to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||
| className="animate-[wiki-blink_.25s_steps(2,end)_infinite] | |||
| dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]" | |||
| title={`${ tag.name } Wiki が存在しません.`}> | |||
| ! | |||
| </Link>)} | |||
| </span>)} | |||
| {nestLevel > 0 && ( | |||
| <span | |||
| className="ml-1 mr-1" | |||
| style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}> | |||
| ↳ | |||
| </span>)} | |||
| {linkFlg | |||
| ? ( | |||
| @@ -57,4 +98,4 @@ export default ({ tag, | |||
| {withCount && ( | |||
| <span className="ml-1">{tag.postCount}</span>)} | |||
| </>) | |||
| } | |||
| }) satisfies FC<Props> | |||
| @@ -6,16 +6,18 @@ import { API_BASE_URL } from '@/config' | |||
| import TagSearchBox from './TagSearchBox' | |||
| import type { FC } from 'react' | |||
| import type { Tag } from '@/types' | |||
| const TagSearch: React.FC = () => { | |||
| export default (() => { | |||
| const location = useLocation () | |||
| const navigate = useNavigate () | |||
| const [activeIndex, setActiveIndex] = useState (-1) | |||
| const [search, setSearch] = useState ('') | |||
| const [suggestions, setSuggestions] = useState<Tag[]> ([]) | |||
| const [activeIndex, setActiveIndex] = useState (-1) | |||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | |||
| const whenChanged = async (ev: React.ChangeEvent<HTMLInputElement>) => { | |||
| @@ -24,14 +26,14 @@ const TagSearch: React.FC = () => { | |||
| const q = ev.target.value.trim ().split (' ').at (-1) | |||
| if (!(q)) | |||
| { | |||
| setSuggestions ([]) | |||
| return | |||
| setSuggestions ([]) | |||
| return | |||
| } | |||
| const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } }) | |||
| const data = res.data as Tag[] | |||
| setSuggestions (data) | |||
| if (suggestions.length) | |||
| if (suggestions.length > 0) | |||
| setSuggestionsVsbl (true) | |||
| } | |||
| @@ -52,7 +54,7 @@ const TagSearch: React.FC = () => { | |||
| case 'Enter': | |||
| if (activeIndex < 0) | |||
| break | |||
| break | |||
| ev.preventDefault () | |||
| const selected = suggestions[activeIndex] | |||
| selected && handleTagSelect (selected) | |||
| @@ -65,8 +67,8 @@ const TagSearch: React.FC = () => { | |||
| } | |||
| if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0)) | |||
| { | |||
| navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`) | |||
| setSuggestionsVsbl (false) | |||
| navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`) | |||
| setSuggestionsVsbl (false) | |||
| } | |||
| } | |||
| @@ -86,18 +88,16 @@ const TagSearch: React.FC = () => { | |||
| return ( | |||
| <div className="relative w-full"> | |||
| <input type="text" | |||
| placeholder="タグ検索..." | |||
| value={search} | |||
| onChange={whenChanged} | |||
| onFocus={() => setSuggestionsVsbl (true)} | |||
| onBlur={() => setSuggestionsVsbl (false)} | |||
| onKeyDown={handleKeyDown} | |||
| className="w-full px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-800 dark:text-white" /> | |||
| <TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]} | |||
| activeIndex={activeIndex} | |||
| onSelect={handleTagSelect} /> | |||
| <input type="text" | |||
| placeholder="タグ検索..." | |||
| value={search} | |||
| onChange={whenChanged} | |||
| onFocus={() => setSuggestionsVsbl (true)} | |||
| onBlur={() => setSuggestionsVsbl (false)} | |||
| onKeyDown={handleKeyDown} | |||
| className="w-full px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-800 dark:text-white"/> | |||
| <TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]} | |||
| activeIndex={activeIndex} | |||
| onSelect={handleTagSelect}/> | |||
| </div>) | |||
| } | |||
| export default TagSearch | |||
| }) satisfies FC | |||
| @@ -1,5 +1,7 @@ | |||
| import { cn } from '@/lib/utils' | |||
| import type { FC } from 'react' | |||
| import type { Tag } from '@/types' | |||
| type Props = { suggestions: Tag[] | |||
| @@ -7,8 +9,8 @@ type Props = { suggestions: Tag[] | |||
| onSelect: (tag: Tag) => void } | |||
| export default ({ suggestions, activeIndex, onSelect }: Props) => { | |||
| if (!(suggestions.length)) | |||
| export default (({ suggestions, activeIndex, onSelect }: Props) => { | |||
| if (suggestions.length === 0) | |||
| return | |||
| return ( | |||
| @@ -19,10 +21,9 @@ export default ({ suggestions, activeIndex, onSelect }: Props) => { | |||
| <li key={tag.id} | |||
| className={cn ('px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700', | |||
| i === activeIndex && 'bg-gray-300 dark:bg-gray-700')} | |||
| onMouseDown={() => onSelect (tag)} | |||
| > | |||
| onMouseDown={() => onSelect (tag)}> | |||
| {tag.name} | |||
| {<span className="ml-2 text-sm text-gray-400">{tag.postCount}</span>} | |||
| </li>))} | |||
| </ul>) | |||
| } | |||
| }) satisfies FC<Props> | |||
| @@ -1,4 +1,5 @@ | |||
| import axios from 'axios' | |||
| import { AnimatePresence, motion } from 'framer-motion' | |||
| import { useEffect, useState } from 'react' | |||
| import { useLocation, useNavigate } from 'react-router-dom' | |||
| @@ -8,7 +9,8 @@ import SectionTitle from '@/components/common/SectionTitle' | |||
| import SidebarComponent from '@/components/layout/SidebarComponent' | |||
| import { API_BASE_URL } from '@/config' | |||
| import { CATEGORIES } from '@/consts' | |||
| import { cn } from '@/lib/utils' | |||
| import type { FC } from 'react' | |||
| import type { Post, Tag } from '@/types' | |||
| @@ -17,7 +19,7 @@ type TagByCategory = Record<string, Tag[]> | |||
| type Props = { posts: Post[] } | |||
| export default ({ posts }: Props) => { | |||
| export default (({ posts }: Props) => { | |||
| const navigate = useNavigate () | |||
| const [tagsVsbl, setTagsVsbl] = useState (false) | |||
| @@ -56,49 +58,73 @@ export default ({ posts }: Props) => { | |||
| setTags (tagsTmp) | |||
| }, [posts]) | |||
| const TagBlock = ( | |||
| <> | |||
| <SectionTitle>タグ</SectionTitle> | |||
| <ul> | |||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | |||
| tags[cat].map (tag => ( | |||
| <li key={tag.id} className="mb-1"> | |||
| <TagLink tag={tag}/> | |||
| </li>))) : [])} | |||
| </ul> | |||
| <SectionTitle>関聯</SectionTitle> | |||
| {posts.length > 0 && ( | |||
| <a href="#" | |||
| onClick={ev => { | |||
| ev.preventDefault () | |||
| void ((async () => { | |||
| try | |||
| { | |||
| const { data } = await axios.get (`${ API_BASE_URL }/posts/random`, | |||
| { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), | |||
| match: (anyFlg ? 'any' : 'all') } }) | |||
| navigate (`/posts/${ (data as Post).id }`) | |||
| } | |||
| catch | |||
| { | |||
| ; | |||
| } | |||
| }) ()) | |||
| }}> | |||
| ランダム | |||
| </a>)} | |||
| </>) | |||
| return ( | |||
| <SidebarComponent> | |||
| <TagSearch /> | |||
| <div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}> | |||
| <SectionTitle>タグ</SectionTitle> | |||
| <ul> | |||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | |||
| tags[cat].map (tag => ( | |||
| <li key={tag.id} className="mb-1"> | |||
| <TagLink tag={tag} /> | |||
| </li>))) : [])} | |||
| </ul> | |||
| <SectionTitle>関聯</SectionTitle> | |||
| {posts.length > 0 && ( | |||
| <a href="#" | |||
| onClick={ev => { | |||
| ev.preventDefault () | |||
| void ((async () => { | |||
| try | |||
| { | |||
| const { data } = await axios.get (`${ API_BASE_URL }/posts/random`, | |||
| { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (','), | |||
| match: (anyFlg ? 'any' : 'all') } }) | |||
| navigate (`/posts/${ (data as Post).id }`) | |||
| } | |||
| catch | |||
| { | |||
| ; | |||
| } | |||
| }) ()) | |||
| }}> | |||
| ランダム | |||
| </a>)} | |||
| <TagSearch/> | |||
| <div className="hidden md:block mt-4"> | |||
| {TagBlock} | |||
| </div> | |||
| <AnimatePresence initial={false}> | |||
| {tagsVsbl && ( | |||
| <motion.div | |||
| key="sptags" | |||
| className="md:hidden mt-4" | |||
| variants={{ hidden: { clipPath: 'inset(0 0 100% 0)', | |||
| height: 0 }, | |||
| visible: { clipPath: 'inset(0 0 0% 0)', | |||
| height: 'auto'} }} | |||
| initial="hidden" | |||
| animate="visible" | |||
| exit="hidden" | |||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||
| {TagBlock} | |||
| </motion.div>)} | |||
| </AnimatePresence> | |||
| <a href="#" | |||
| className="md:hidden block my-2 text-center text-sm | |||
| text-gray-500 hover:text-gray-400 | |||
| dark:text-gray-300 dark:hover:text-gray-100" | |||
| onClick={ev => { | |||
| ev.preventDefault () | |||
| setTagsVsbl (!(tagsVsbl)) | |||
| setTagsVsbl (v => !(v)) | |||
| }}> | |||
| {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} | |||
| </a> | |||
| </SidebarComponent>) | |||
| } | |||
| }) satisfies FC<Props> | |||
| @@ -1,6 +1,7 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import { Fragment, useState, useEffect } from 'react' | |||
| import { AnimatePresence, motion } from 'framer-motion' | |||
| import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' | |||
| import { Link, useLocation } from 'react-router-dom' | |||
| import Separator from '@/components/MenuSeparator' | |||
| @@ -9,14 +10,38 @@ import { API_BASE_URL } from '@/config' | |||
| import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | |||
| import { cn } from '@/lib/utils' | |||
| import type { FC } from 'react' | |||
| import type { Menu, Tag, User, WikiPage } from '@/types' | |||
| type Props = { user: User | null } | |||
| export default ({ user }: Props) => { | |||
| export default (({ user }: Props) => { | |||
| const location = useLocation () | |||
| const dirRef = useRef<(-1) | 1> (1) | |||
| const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([]) | |||
| const navRef = useRef<HTMLDivElement | null> (null) | |||
| const measure = () => { | |||
| const nav = navRef.current | |||
| const el = itemsRef.current[activeIdx] | |||
| if (!(nav) || !(el) || activeIdx < 0) | |||
| return | |||
| const navRect = nav.getBoundingClientRect () | |||
| const elRect = el.getBoundingClientRect () | |||
| setHl ({ left: elRect.left - navRect.left, | |||
| width: elRect.width, | |||
| visible: true }) | |||
| } | |||
| const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({ | |||
| left: 0, | |||
| width: 0, | |||
| visible: false }) | |||
| const [menuOpen, setMenuOpen] = useState (false) | |||
| const [openItemIdx, setOpenItemIdx] = useState (-1) | |||
| const [postCount, setPostCount] = useState<number | null> (null) | |||
| @@ -28,20 +53,20 @@ export default ({ user }: Props) => { | |||
| { name: '広場', to: '/posts', subMenu: [ | |||
| { name: '一覧', to: '/posts' }, | |||
| { name: '投稿追加', to: '/posts/new' }, | |||
| { name: '耕作履歴', to: '/posts/changes' }, | |||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | |||
| { name: 'タグ', to: '/tags', subMenu: [ | |||
| { name: 'タグ一覧', to: '/tags', visible: false }, | |||
| { name: '別名タグ', to: '/tags/aliases', visible: false }, | |||
| { name: '上位タグ', to: '/tags/implications', visible: false }, | |||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | |||
| { name: 'タグのつけ方', to: '/wiki/ヘルプ:タグのつけ方' }, | |||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, | |||
| { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ | |||
| { name: '検索', to: '/wiki' }, | |||
| { name: '新規', to: '/wiki/new' }, | |||
| { name: '全体履歴', to: '/wiki/changes' }, | |||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' }, | |||
| { component: <Separator />, visible: wikiPageFlg }, | |||
| { component: <Separator/>, visible: wikiPageFlg }, | |||
| { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`, | |||
| visible: wikiPageFlg }, | |||
| { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, | |||
| @@ -51,6 +76,32 @@ export default ({ user }: Props) => { | |||
| { name: 'お前', to: `/users/${ user?.id }`, visible: false }, | |||
| { name: '設定', to: '/users/settings', visible: Boolean (user) }] }] | |||
| const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to)) | |||
| const prevActiveIdxRef = useRef<number> (activeIdx) | |||
| if (activeIdx !== prevActiveIdxRef.current) | |||
| { | |||
| dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1 | |||
| prevActiveIdxRef.current = activeIdx | |||
| } | |||
| const dir = dirRef.current | |||
| useLayoutEffect (() => { | |||
| if (activeIdx < 0) | |||
| return | |||
| const raf = requestAnimationFrame (measure) | |||
| const onResize = () => requestAnimationFrame (measure) | |||
| addEventListener ('resize', onResize) | |||
| return () => { | |||
| cancelAnimationFrame (raf) | |||
| removeEventListener ('resize', onResize) | |||
| } | |||
| }, [activeIdx]) | |||
| useEffect (() => { | |||
| const unsubscribe = WikiIdBus.subscribe (setWikiId) | |||
| return () => unsubscribe () | |||
| @@ -96,19 +147,29 @@ export default ({ user }: Props) => { | |||
| ぼざクリ タグ広場 | |||
| </Link> | |||
| {menu.map ((item, i) => ( | |||
| <Link key={i} | |||
| to={item.to} | |||
| className={cn ('hidden md:flex h-full items-center', | |||
| (location.pathname.startsWith (item.base || item.to) | |||
| ? 'bg-yellow-200 dark:bg-red-950 px-4 font-bold' | |||
| : 'px-2'))}> | |||
| {item.name} | |||
| </Link> | |||
| ))} | |||
| <div ref={navRef} className="relative hidden md:flex h-full items-center"> | |||
| <div aria-hidden | |||
| className={cn ('absolute top-1/2 -translate-y-1/2 h-full', | |||
| 'bg-yellow-200 dark:bg-red-950', | |||
| 'transition-[transform,width] duration-200 ease-out')} | |||
| style={{ width: hl.width, | |||
| transform: `translate(${ hl.left }px, -50%)`, | |||
| opacity: hl.visible ? 1 : 0 }}/> | |||
| {menu.map ((item, i) => ( | |||
| <Link key={i} | |||
| to={item.to} | |||
| ref={el => { | |||
| itemsRef.current[i] = el | |||
| }} | |||
| className={cn ('relative z-10 flex h-full items-center px-5', | |||
| (i === openItemIdx) && 'font-bold')}> | |||
| {item.name} | |||
| </Link>))} | |||
| </div> | |||
| </div> | |||
| <TopNavUser user={user} /> | |||
| <TopNavUser user={user}/> | |||
| <a href="#" | |||
| className="md:hidden ml-auto pr-4 | |||
| @@ -122,49 +183,101 @@ export default ({ user }: Props) => { | |||
| </a> | |||
| </nav> | |||
| <div className="hidden md:flex bg-yellow-200 dark:bg-red-950 | |||
| items-center w-full min-h-[40px] px-3"> | |||
| {menu.find (item => location.pathname.startsWith (item.base || item.to))?.subMenu | |||
| .filter (item => item.visible ?? true) | |||
| .map ((item, i) => 'component' in item ? item.component : ( | |||
| <Link key={i} | |||
| to={item.to} | |||
| className="h-full flex items-center px-3"> | |||
| {item.name} | |||
| </Link>))} | |||
| <div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950 | |||
| items-center w-full min-h-[40px] overflow-hidden"> | |||
| <AnimatePresence initial={false} custom={dir}> | |||
| <motion.div | |||
| key={activeIdx} | |||
| custom={dir} | |||
| variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }), | |||
| centre: { y: 0, opacity: 1 }, | |||
| exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }} | |||
| className="absolute inset-0 flex items-center px-3" | |||
| initial="enter" | |||
| animate="centre" | |||
| exit="exit" | |||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||
| {(menu[activeIdx]?.subMenu ?? []) | |||
| .filter (item => item.visible ?? true) | |||
| .map ((item, i) => ( | |||
| 'component' in item | |||
| ? <Fragment key={`c-${ i }`}>{item.component}</Fragment> | |||
| : ( | |||
| <Link key={`l-${ i }`} | |||
| to={item.to} | |||
| className="h-full flex items-center px-3"> | |||
| {item.name} | |||
| </Link>)))} | |||
| </motion.div> | |||
| </AnimatePresence> | |||
| </div> | |||
| <div className={cn (menuOpen ? 'flex flex-col md:hidden' : 'hidden', | |||
| 'bg-yellow-200 dark:bg-red-975 items-start')}> | |||
| <Separator /> | |||
| {menu.map ((item, i) => ( | |||
| <Fragment key={i}> | |||
| <Link to={i === openItemIdx ? item.to : '#'} | |||
| className={cn ('w-full min-h-[40px] flex items-center pl-8', | |||
| ((i === openItemIdx) | |||
| && 'font-bold bg-yellow-50 dark:bg-red-950'))} | |||
| onClick={ev => { | |||
| if (i !== openItemIdx) | |||
| { | |||
| ev.preventDefault () | |||
| setOpenItemIdx (i) | |||
| } | |||
| }}> | |||
| {item.name} | |||
| </Link> | |||
| {i === openItemIdx && ( | |||
| item.subMenu | |||
| .filter (subItem => subItem.visible ?? true) | |||
| .map ((subItem, j) => 'component' in subItem ? subItem.component : ( | |||
| <Link key={j} | |||
| to={subItem.to} | |||
| className="w-full min-h-[36px] flex items-center pl-12 | |||
| bg-yellow-50 dark:bg-red-950"> | |||
| {subItem.name} | |||
| </Link>)))} | |||
| </Fragment>))} | |||
| <TopNavUser user={user} sp /> | |||
| <Separator /> | |||
| </div> | |||
| <AnimatePresence initial={false}> | |||
| {menuOpen && ( | |||
| <motion.div | |||
| key="spmenu" | |||
| className={cn ('flex flex-col md:hidden', | |||
| 'bg-yellow-200 dark:bg-red-975 items-start')} | |||
| variants={{ closed: { clipPath: 'inset(0 0 100% 0)', | |||
| height: 0 }, | |||
| open: { clipPath: 'inset(0 0 0% 0)', | |||
| height: 'auto' } }} | |||
| initial="closed" | |||
| animate="open" | |||
| exit="closed" | |||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||
| <Separator/> | |||
| {menu.map ((item, i) => ( | |||
| <Fragment key={i}> | |||
| <Link to={i === openItemIdx ? item.to : '#'} | |||
| className={cn ('w-full min-h-[40px] flex items-center pl-8', | |||
| ((i === openItemIdx) | |||
| && 'font-bold bg-yellow-50 dark:bg-red-950'))} | |||
| onClick={ev => { | |||
| if (i !== openItemIdx) | |||
| { | |||
| ev.preventDefault () | |||
| setOpenItemIdx (i) | |||
| } | |||
| }}> | |||
| {item.name} | |||
| </Link> | |||
| <AnimatePresence initial={false}> | |||
| {i === openItemIdx && ( | |||
| <motion.div | |||
| key={`sp-sub-${ i }`} | |||
| className="w-full bg-yellow-50 dark:bg-red-950" | |||
| variants={{ closed: { clipPath: 'inset(0 0 100% 0)', | |||
| height: 0, | |||
| opacity: 0 }, | |||
| open: { clipPath: 'inset(0 0 0% 0)', | |||
| height: 'auto', | |||
| opacity: 1 } }} | |||
| initial="closed" | |||
| animate="open" | |||
| exit="closed" | |||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||
| {item.subMenu | |||
| .filter (subItem => subItem.visible ?? true) | |||
| .map ((subItem, j) => ( | |||
| 'component' in subItem | |||
| ? ( | |||
| <Fragment key={`sp-c-${ i }-${ j }`}> | |||
| {subItem.component} | |||
| </Fragment>) | |||
| : ( | |||
| <Link key={`sp-l-${ i }-${ j }`} | |||
| to={subItem.to} | |||
| className="w-full min-h-[36px] flex items-center pl-12"> | |||
| {subItem.name} | |||
| </Link>)))} | |||
| </motion.div>)} | |||
| </AnimatePresence> | |||
| </Fragment>))} | |||
| <TopNavUser user={user} sp/> | |||
| <Separator/> | |||
| </motion.div>)} | |||
| </AnimatePresence> | |||
| </>) | |||
| } | |||
| }) satisfies FC<Props> | |||
| @@ -3,13 +3,15 @@ import { Link } from 'react-router-dom' | |||
| import Separator from '@/components/MenuSeparator' | |||
| import { cn } from '@/lib/utils' | |||
| import type { FC } from 'react' | |||
| import type { User } from '@/types' | |||
| type Props = { user: User | null, | |||
| sp?: boolean } | |||
| export default ({ user, sp }: Props) => { | |||
| export default (({ user, sp }: Props) => { | |||
| if (!(user)) | |||
| return | |||
| @@ -21,10 +23,10 @@ export default ({ user, sp }: Props) => { | |||
| return ( | |||
| <> | |||
| {sp && <Separator />} | |||
| {sp && <Separator/>} | |||
| <Link to="/users/settings" | |||
| className={className}> | |||
| {user.name || '名もなきニジラー'} | |||
| </Link> | |||
| </>) | |||
| } | |||
| }) satisfies FC<Props> | |||
| @@ -0,0 +1,21 @@ | |||
| import type { FC } from 'react' | |||
| type Props = { | |||
| userId: string | |||
| statusId: string } | |||
| export default (({ userId, statusId }: Props) => { | |||
| const now = (new Date).toLocaleDateString () | |||
| return ( | |||
| <div> | |||
| <blockquote className="twitter-tweet"> | |||
| <p lang="ja" dir="ltr"> | |||
| Loading... | |||
| </p> | |||
| — <a href={`https://twitter.com/${ userId }?ref_src=twsrc%3Etfw`}>@{userId}</a> <a href={`https://twitter.com/${ userId }/status/${ statusId }?ref_src=twsrc%5Etfw`}>{now}</a> | |||
| </blockquote> | |||
| <script async src="https://platform.twitter.com/widgets.js" charSet="utf-8"/> | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| @@ -1,14 +1,105 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import { useEffect, useState } from 'react' | |||
| import ReactMarkdown from 'react-markdown' | |||
| import { Link } from 'react-router-dom' | |||
| import remarkGFM from 'remark-gfm' | |||
| import SectionTitle from '@/components/common/SectionTitle' | |||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | |||
| import { API_BASE_URL } from '@/config' | |||
| import type { FC } from 'react' | |||
| import type { Components } from 'react-markdown' | |||
| import type { WikiPage } from '@/types' | |||
| type Props = { title: string | |||
| body?: string } | |||
| const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>, | |||
| h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>, | |||
| ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>, | |||
| ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, | |||
| a: (({ href, children }) => ( | |||
| ['/', '.'].some (e => href?.startsWith (e)) | |||
| ? <Link to={href!}>{children}</Link> | |||
| : ( | |||
| <a href={href} | |||
| target="_blank" | |||
| rel="noopener noreferrer"> | |||
| {children} | |||
| </a>))) } as const satisfies Components | |||
| export default (({ title, body }: Props) => { | |||
| const [pageNames, setPageNames] = useState<string[]> ([]) | |||
| const [realBody, setRealBody] = useState<string> ('') | |||
| useEffect (() => { | |||
| if (!(body)) | |||
| return | |||
| void (async () => { | |||
| try | |||
| { | |||
| const res = await axios.get (`${ API_BASE_URL }/wiki`) | |||
| const data = toCamel (res.data as any, { deep: true }) as WikiPage[] | |||
| setPageNames (data.map (page => page.title).sort ((a, b) => b.length - a.length)) | |||
| } | |||
| catch | |||
| { | |||
| setPageNames ([]) | |||
| } | |||
| }) () | |||
| }, []) | |||
| useEffect (() => { | |||
| setRealBody ('') | |||
| }, [body]) | |||
| useEffect (() => { | |||
| if (!(body)) | |||
| return | |||
| const matchIndices = (target: string, keyword: string) => { | |||
| const indices: number[] = [] | |||
| let pos = 0 | |||
| let idx | |||
| while ((idx = target.indexOf (keyword, pos)) >= 0) | |||
| { | |||
| indices.push (idx) | |||
| pos = idx + keyword.length | |||
| } | |||
| return indices | |||
| } | |||
| const linkIndices = (text: string, names: string[]): [string, [number, number]][] => { | |||
| const result: [string, [number, number]][] = [] | |||
| names.forEach (name => { | |||
| matchIndices (text, name).forEach (idx => { | |||
| const start = idx | |||
| const end = idx + name.length | |||
| const overlaps = result.some (([, [st, ed]]) => start < ed && end > st) | |||
| if (!(overlaps)) | |||
| result.push ([name, [start, end]]) | |||
| }) | |||
| }) | |||
| return result.sort (([, [a]], [, [b]]) => b - a) | |||
| } | |||
| setRealBody ( | |||
| linkIndices (body, pageNames).reduce ((acc, [name, [start, end]]) => ( | |||
| acc.slice (0, start) | |||
| + `[${ name }](/wiki/${ encodeURIComponent (name) })` | |||
| + acc.slice (end)), body)) | |||
| }, [body, pageNames]) | |||
| export default ({ title, body }: Props) => ( | |||
| <ReactMarkdown components={{ a: ( | |||
| ({ href, children }) => (['/', '.'].some (e => href?.startsWith (e)) | |||
| ? <Link to={href!}>{children}</Link> | |||
| : <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>)) }}> | |||
| {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||
| </ReactMarkdown>) | |||
| return ( | |||
| <ReactMarkdown components={mdComponents} remarkPlugins={[remarkGFM]}> | |||
| {realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||
| </ReactMarkdown>) | |||
| }) 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> | |||
| @@ -1,9 +1,9 @@ | |||
| import React from 'react' | |||
| import type { FC, ReactNode } from 'react' | |||
| type Props = { children: React.ReactNode } | |||
| type Props = { children: ReactNode } | |||
| export default ({ children }: Props) => ( | |||
| export default (({ children }: Props) => ( | |||
| <div className="max-w-xl mx-auto p-4 space-y-4"> | |||
| {children} | |||
| </div>) | |||
| </div>)) satisfies FC<Props> | |||
| @@ -21,7 +21,7 @@ export default ({ children, checkBox }: Props) => { | |||
| <label className="flex items-center block gap-1"> | |||
| <input type="checkbox" | |||
| checked={checkBox.checked} | |||
| onChange={checkBox.onChange} /> | |||
| onChange={checkBox.onChange}/> | |||
| {checkBox.label} | |||
| </label> | |||
| </div>) | |||
| @@ -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,10 +1,9 @@ | |||
| import React from 'react' | |||
| import { forwardRef } from 'react' | |||
| type Props = { value?: string | |||
| onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void } | |||
| import type { TextareaHTMLAttributes } from 'react' | |||
| type Props = TextareaHTMLAttributes<HTMLTextAreaElement> | |||
| export default ({ value, onChange }: Props) => ( | |||
| <textarea className="rounded border w-full p-2 h-32" | |||
| value={value} | |||
| onChange={onChange} />) | |||
| export default forwardRef<HTMLTextAreaElement, Props> (({ ...props }, ref) => ( | |||
| <textarea ref={ref} className="rounded border w-full p-2 h-32" {...props}/>)) | |||
| @@ -51,7 +51,7 @@ export default ({ visible, onVisibleChange, setUser }: Props) => { | |||
| <div className="flex gap-2"> | |||
| <Input placeholder="引継ぎコードを入力" | |||
| value={inputCode} | |||
| onChange={ev => setInputCode (ev.target.value)} /> | |||
| onChange={ev => setInputCode (ev.target.value)}/> | |||
| <Button onClick={handleTransfer}>引継ぐ</Button> | |||
| </div> | |||
| </DialogContent> | |||
| @@ -1,3 +1,5 @@ | |||
| @import "@fontsource-variable/noto-sans-jp"; | |||
| @tailwind base; | |||
| @tailwind components; | |||
| @tailwind utilities; | |||
| @@ -21,7 +23,7 @@ | |||
| :root | |||
| { | |||
| font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; | |||
| font-family: "Noto Sans JP Variable", system-ui, Avenir, Helvetica, Arial, sans-serif; | |||
| line-height: 1.5; | |||
| font-weight: 400; | |||
| @@ -94,3 +96,15 @@ button:focus-visible | |||
| background-color: #f9f9f9; | |||
| } | |||
| } | |||
| @keyframes wiki-blink | |||
| { | |||
| 0%, 100% { color: #dc2626; } | |||
| 50% { color: #2563eb; } | |||
| } | |||
| @keyframes wiki-blink-dark | |||
| { | |||
| 0%, 100% { color: #f87171; } | |||
| 50% { color: #60a5fa; } | |||
| } | |||
| @@ -8,5 +8,5 @@ const helmetContext = { } | |||
| createRoot (document.getElementById ('root')!).render ( | |||
| <HelmetProvider context={helmetContext}> | |||
| <App /> | |||
| <App/> | |||
| </HelmetProvider>) | |||
| @@ -1,4 +1,4 @@ | |||
| import ErrorScreen from '@/components/ErrorScreen' | |||
| export default () => <ErrorScreen status={403} /> | |||
| export default () => <ErrorScreen status={403}/> | |||
| @@ -1,4 +1,4 @@ | |||
| import ErrorScreen from '@/components/ErrorScreen' | |||
| export default () => <ErrorScreen status={404} /> | |||
| export default () => <ErrorScreen status={404}/> | |||
| @@ -1,4 +1,4 @@ | |||
| import ErrorScreen from '@/components/ErrorScreen' | |||
| export default () => <ErrorScreen status={503} /> | |||
| export default () => <ErrorScreen status={503}/> | |||
| @@ -6,8 +6,8 @@ import { useParams } from 'react-router-dom' | |||
| import PostList from '@/components/PostList' | |||
| import TagDetailSidebar from '@/components/TagDetailSidebar' | |||
| import NicoViewer from '@/components/NicoViewer' | |||
| import PostEditForm from '@/components/PostEditForm' | |||
| import PostEmbed from '@/components/PostEmbed' | |||
| import TabGroup, { Tab } from '@/components/common/TabGroup' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { Button } from '@/components/ui/button' | |||
| @@ -17,12 +17,14 @@ import { cn } from '@/lib/utils' | |||
| import NotFound from '@/pages/NotFound' | |||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | |||
| import type { FC } from 'react' | |||
| import type { Post, User } from '@/types' | |||
| type Props = { user: User | null } | |||
| export default ({ user }: Props) => { | |||
| export default (({ user }: Props) => { | |||
| const { id } = useParams () | |||
| const [post, setPost] = useState<Post | null> (null) | |||
| @@ -72,15 +74,11 @@ export default ({ user }: Props) => { | |||
| switch (status) | |||
| { | |||
| case 404: | |||
| return <NotFound /> | |||
| return <NotFound/> | |||
| case 503: | |||
| return <ServiceUnavailable /> | |||
| return <ServiceUnavailable/> | |||
| } | |||
| const url = post ? new URL (post.url) : null | |||
| const nicoFlg = url?.hostname.split ('.').slice (-2).join ('.') === 'nicovideo.jp' | |||
| const match = nicoFlg ? url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/) : null | |||
| const videoId = match?.[0] ?? '' | |||
| const viewedClass = (post?.viewed | |||
| ? 'bg-blue-600 hover:bg-blue-700' | |||
| : 'bg-gray-500 hover:bg-gray-600') | |||
| @@ -89,22 +87,17 @@ export default ({ user }: Props) => { | |||
| <div className="md:flex md:flex-1"> | |||
| <Helmet> | |||
| {(post?.thumbnail || post?.thumbnailBase) && ( | |||
| <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase} />)} | |||
| <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} | |||
| {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} | |||
| </Helmet> | |||
| <div className="hidden md:block"> | |||
| <TagDetailSidebar post={post} /> | |||
| <TagDetailSidebar post={post}/> | |||
| </div> | |||
| <MainArea> | |||
| {post | |||
| ? ( | |||
| <> | |||
| {nicoFlg | |||
| ? ( | |||
| <NicoViewer id={videoId} | |||
| width={640} | |||
| height={360} />) | |||
| : <img src={post.thumbnail} alt={post.url} className="mb-4 w-full" />} | |||
| <PostEmbed post={post}/> | |||
| <Button onClick={changeViewedFlg} | |||
| className={cn ('text-white', viewedClass)}> | |||
| {post.viewed ? '閲覧済' : '未閲覧'} | |||
| @@ -112,7 +105,7 @@ export default ({ user }: Props) => { | |||
| <TabGroup> | |||
| <Tab name="関聯"> | |||
| {post.related.length > 0 | |||
| ? <PostList posts={post.related} /> | |||
| ? <PostList posts={post.related}/> | |||
| : 'まだないよ(笑)'} | |||
| </Tab> | |||
| {['admin', 'member'].some (r => user?.role === r) && ( | |||
| @@ -121,14 +114,14 @@ export default ({ user }: Props) => { | |||
| onSave={newPost => { | |||
| setPost (newPost) | |||
| toast ({ description: '更新しました.' }) | |||
| }} /> | |||
| }}/> | |||
| </Tab>)} | |||
| </TabGroup> | |||
| </>) | |||
| : 'Loading...'} | |||
| </MainArea> | |||
| <div className="md:hidden"> | |||
| <TagDetailSidebar post={post} /> | |||
| <TagDetailSidebar post={post}/> | |||
| </div> | |||
| </div>) | |||
| } | |||
| }) satisfies FC<Props> | |||
| @@ -0,0 +1,104 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import { useEffect, useState } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import { Link, useLocation } from 'react-router-dom' | |||
| import TagLink from '@/components/TagLink' | |||
| import PageTitle from '@/components/common/PageTitle' | |||
| import Pagination from '@/components/common/Pagination' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||
| import type { FC } from 'react' | |||
| import type { PostTagChange } from '@/types' | |||
| export default (() => { | |||
| const [changes, setChanges] = useState<PostTagChange[]> ([]) | |||
| const [totalPages, setTotalPages] = useState<number> (0) | |||
| const location = useLocation () | |||
| const query = new URLSearchParams (location.search) | |||
| const id = query.get ('id') | |||
| const page = Number (query.get ('page') ?? 1) | |||
| const limit = Number (query.get ('limit') ?? 20) | |||
| // 投稿列の結合で使用 | |||
| let rowsCnt: number | |||
| useEffect (() => { | |||
| void (async () => { | |||
| const res = await axios.get (`${ API_BASE_URL }/posts/changes`, | |||
| { params: { ...(id && { id }), page, limit } }) | |||
| const data = toCamel (res.data as any, { deep: true }) as { | |||
| changes: PostTagChange[] | |||
| count: number } | |||
| setChanges (data.changes) | |||
| setTotalPages (Math.ceil (data.count / limit)) | |||
| }) () | |||
| }, [id, page, limit]) | |||
| return ( | |||
| <MainArea> | |||
| <Helmet> | |||
| <title>{`耕作履歴 | ${ SITE_TITLE }`}</title> | |||
| </Helmet> | |||
| <PageTitle> | |||
| 耕作履歴 | |||
| {id && <>: 投稿 {<Link to={`/posts/${ id }`}>#{id}</Link>}</>} | |||
| </PageTitle> | |||
| <table className="table-auto w-full border-collapse"> | |||
| <thead> | |||
| <tr> | |||
| <th className="p-2 text-left">投稿</th> | |||
| <th className="p-2 text-left">変更</th> | |||
| <th className="p-2 text-left">日時</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {changes.map ((change, i) => { | |||
| let withPost = i === 0 || change.post.id !== changes[i - 1].post.id | |||
| if (withPost) | |||
| { | |||
| rowsCnt = 1 | |||
| for (let j = i + 1; | |||
| (j < changes.length | |||
| && change.post.id === changes[j].post.id); | |||
| ++j) | |||
| ++rowsCnt | |||
| } | |||
| return ( | |||
| <tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}> | |||
| {withPost && ( | |||
| <td className="align-top" rowSpan={rowsCnt}> | |||
| <Link to={`/posts/${ change.post.id }`}> | |||
| <img src={change.post.thumbnail || change.post.thumbnailBase || undefined} | |||
| alt={change.post.title || change.post.url} | |||
| title={change.post.title || change.post.url || undefined} | |||
| className="w-40"/> | |||
| </Link> | |||
| </td>)} | |||
| <td> | |||
| <TagLink tag={change.tag} withWiki={false} withCount={false}/> | |||
| {`を${ change.changeType === 'add' ? '追加' : '削除' }`} | |||
| </td> | |||
| <td> | |||
| {change.user ? ( | |||
| <Link to={`/users/${ change.user.id }`}> | |||
| {change.user.name} | |||
| </Link>) : 'bot 操作'} | |||
| <br/> | |||
| {change.timestamp} | |||
| </td> | |||
| </tr>) | |||
| })} | |||
| </tbody> | |||
| </table> | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| @@ -7,6 +7,7 @@ import { Link, useLocation, useNavigationType } from 'react-router-dom' | |||
| import PostList from '@/components/PostList' | |||
| import TagSidebar from '@/components/TagSidebar' | |||
| import WikiBody from '@/components/WikiBody' | |||
| import Pagination from '@/components/common/Pagination' | |||
| import TabGroup, { Tab } from '@/components/common/TabGroup' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||
| @@ -23,6 +24,7 @@ export default () => { | |||
| const [cursor, setCursor] = useState ('') | |||
| const [loading, setLoading] = useState (false) | |||
| const [posts, setPosts] = useState<Post[]> ([]) | |||
| const [totalPages, setTotalPages] = useState (0) | |||
| const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) | |||
| const loadMore = async (withCursor: boolean) => { | |||
| @@ -31,15 +33,19 @@ export default () => { | |||
| const res = await axios.get (`${ API_BASE_URL }/posts`, { | |||
| params: { tags: tags.join (' '), | |||
| match: anyFlg ? 'any' : 'all', | |||
| limit: '20', | |||
| ...(page && { page }), | |||
| ...(limit && { limit }), | |||
| ...(withCursor && { cursor }) } }) | |||
| const data = toCamel (res.data as any, { deep: true }) as { posts: Post[] | |||
| nextCursor: string } | |||
| const data = toCamel (res.data as any, { deep: true }) as { | |||
| posts: Post[] | |||
| count: number | |||
| nextCursor: string } | |||
| setPosts (posts => ( | |||
| [...((new Map ([...(withCursor ? posts : []), ...data.posts] | |||
| .map (post => [post.id, post]))) | |||
| .values ())])) | |||
| setCursor (data.nextCursor) | |||
| setTotalPages (Math.ceil (data.count / limit)) | |||
| setLoading (false) | |||
| } | |||
| @@ -49,6 +55,8 @@ export default () => { | |||
| const tagsQuery = query.get ('tags') ?? '' | |||
| const anyFlg = query.get ('match') === 'any' | |||
| const tags = tagsQuery.split (' ').filter (e => e !== '') | |||
| const page = Number (query.get ('page') ?? 1) | |||
| const limit = Number (query.get ('limit') ?? 20) | |||
| useEffect(() => { | |||
| const observer = new IntersectionObserver (entries => { | |||
| @@ -65,7 +73,8 @@ export default () => { | |||
| }, [loaderRef, loading]) | |||
| useLayoutEffect (() => { | |||
| const savedState = sessionStorage.getItem (`posts:${ tagsQuery }`) | |||
| // TODO: 無限ロード用 | |||
| const savedState = /* sessionStorage.getItem (`posts:${ tagsQuery }`) */ null | |||
| if (savedState && navigationType === 'POP') | |||
| { | |||
| const { posts, cursor, scroll } = JSON.parse (savedState) | |||
| @@ -111,27 +120,32 @@ export default () => { | |||
| </title> | |||
| </Helmet> | |||
| <TagSidebar posts={posts.slice (0, 20)} /> | |||
| <TagSidebar posts={posts.slice (0, 20)}/> | |||
| <MainArea> | |||
| <TabGroup> | |||
| <Tab name="広場"> | |||
| {posts.length | |||
| {posts.length > 0 | |||
| ? ( | |||
| <PostList posts={posts} onClick={() => { | |||
| const statesToSave = { | |||
| posts, cursor, | |||
| scroll: containerRef.current?.scrollTop ?? 0 } | |||
| sessionStorage.setItem (`posts:${ tagsQuery }`, | |||
| JSON.stringify (statesToSave)) | |||
| }} />) | |||
| <> | |||
| <PostList posts={posts} onClick={() => { | |||
| // TODO: 無限ロード用なので復活時に戻す. | |||
| // const statesToSave = { | |||
| // posts, cursor, | |||
| // scroll: containerRef.current?.scrollTop ?? 0 } | |||
| // sessionStorage.setItem (`posts:${ tagsQuery }`, | |||
| // JSON.stringify (statesToSave)) | |||
| }}/> | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </>) | |||
| : !(loading) && '広場には何もありませんよ.'} | |||
| {loading && 'Loading...'} | |||
| <div ref={loaderRef} className="h-12"></div> | |||
| {/* TODO: 無限ローディング復活までコメント・アウト */} | |||
| {/* <div ref={loaderRef} className="h-12"/> */} | |||
| </Tab> | |||
| {tags.length === 1 && ( | |||
| <Tab name="Wiki"> | |||
| <WikiBody body={wikiPage?.body} title={tags[0]} /> | |||
| <WikiBody title={tags[0]} body={wikiPage?.body}/> | |||
| <div className="my-2"> | |||
| <Link to={`/wiki/${ encodeURIComponent (tags[0]) }`}> | |||
| Wiki を見る | |||
| @@ -3,36 +3,41 @@ import { useEffect, useState, useRef } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import { useNavigate } from 'react-router-dom' | |||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | |||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | |||
| import Form from '@/components/common/Form' | |||
| import Label from '@/components/common/Label' | |||
| import PageTitle from '@/components/common/PageTitle' | |||
| import TextArea from '@/components/common/TextArea' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { Button } from '@/components/ui/button' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||
| import Forbidden from '@/pages/Forbidden' | |||
| import type { FC } from 'react' | |||
| import type { User } from '@/types' | |||
| type Props = { user: User | null } | |||
| export default ({ user }: Props) => { | |||
| export default (({ user }: Props) => { | |||
| if (!(['admin', 'member'].some (r => user?.role === r))) | |||
| return <Forbidden /> | |||
| return <Forbidden/> | |||
| const navigate = useNavigate () | |||
| const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null) | |||
| const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) | |||
| const [tags, setTags] = useState ('') | |||
| const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) | |||
| const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) | |||
| const [thumbnailLoading, setThumbnailLoading] = useState (false) | |||
| const [thumbnailPreview, setThumbnailPreview] = useState<string> ('') | |||
| const [title, setTitle] = useState ('') | |||
| const [titleAutoFlg, setTitleAutoFlg] = useState (true) | |||
| const [titleLoading, setTitleLoading] = useState (false) | |||
| const [url, setURL] = useState ('') | |||
| const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) | |||
| const [thumbnailPreview, setThumbnailPreview] = useState<string> ('') | |||
| const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) | |||
| const [thumbnailLoading, setThumbnailLoading] = useState (false) | |||
| const [tags, setTags] = useState ('') | |||
| const previousURLRef = useRef ('') | |||
| @@ -43,6 +48,10 @@ export default ({ user }: Props) => { | |||
| formData.append ('tags', tags) | |||
| if (thumbnailFile) | |||
| formData.append ('thumbnail', thumbnailFile) | |||
| if (originalCreatedFrom) | |||
| formData.append ('original_created_from', originalCreatedFrom) | |||
| if (originalCreatedBefore) | |||
| formData.append ('original_created_before', originalCreatedBefore) | |||
| try | |||
| { | |||
| @@ -120,18 +129,18 @@ export default ({ user }: Props) => { | |||
| {/* URL */} | |||
| <div> | |||
| <Label>URL</Label> | |||
| <input type="text" | |||
| <input type="url" | |||
| placeholder="例:https://www.nicovideo.jp/watch/..." | |||
| value={url} | |||
| onChange={e => setURL (e.target.value)} | |||
| className="w-full border p-2 rounded" | |||
| onBlur={handleURLBlur} /> | |||
| onBlur={handleURLBlur}/> | |||
| </div> | |||
| {/* タイトル */} | |||
| <div> | |||
| <Label checkBox={{ | |||
| label: '自動', | |||
| label: '自動', | |||
| checked: titleAutoFlg, | |||
| onChange: ev => setTitleAutoFlg (ev.target.checked)}}> | |||
| タイトル | |||
| @@ -141,13 +150,13 @@ export default ({ user }: Props) => { | |||
| value={title} | |||
| placeholder={titleLoading ? 'Loading...' : ''} | |||
| onChange={ev => setTitle (ev.target.value)} | |||
| disabled={titleAutoFlg} /> | |||
| disabled={titleAutoFlg}/> | |||
| </div> | |||
| {/* サムネール */} | |||
| <div> | |||
| <Label checkBox={{ | |||
| label: '自動', | |||
| label: '自動', | |||
| checked: thumbnailAutoFlg, | |||
| onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}> | |||
| サムネール | |||
| @@ -169,20 +178,22 @@ export default ({ user }: Props) => { | |||
| setThumbnailFile (file) | |||
| setThumbnailPreview (URL.createObjectURL (file)) | |||
| } | |||
| }} />)} | |||
| }}/>)} | |||
| {thumbnailPreview && ( | |||
| <img src={thumbnailPreview} | |||
| alt="preview" | |||
| className="mt-2 max-h-48 rounded border" />)} | |||
| className="mt-2 max-h-48 rounded border"/>)} | |||
| </div> | |||
| {/* タグ */} | |||
| {/* TextArea で自由形式にする */} | |||
| <div> | |||
| <Label>タグ</Label> | |||
| <TextArea value={tags} | |||
| onChange={ev => setTags (ev.target.value)} /> | |||
| </div> | |||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | |||
| {/* オリジナルの作成日時 */} | |||
| <PostOriginalCreatedTimeField | |||
| originalCreatedFrom={originalCreatedFrom} | |||
| setOriginalCreatedFrom={setOriginalCreatedFrom} | |||
| originalCreatedBefore={originalCreatedBefore} | |||
| setOriginalCreatedBefore={setOriginalCreatedBefore}/> | |||
| {/* 送信 */} | |||
| <Button onClick={handleSubmit} | |||
| @@ -192,4 +203,4 @@ export default ({ user }: Props) => { | |||
| </Button> | |||
| </Form> | |||
| </MainArea>) | |||
| } | |||
| }) satisfies FC<Props> | |||
| @@ -114,19 +114,19 @@ export default ({ user }: Props) => { | |||
| {nicoTags.map ((tag, i) => ( | |||
| <tr key={i}> | |||
| <td className="p-2"> | |||
| <TagLink tag={tag} withWiki={false} withCount={false} /> | |||
| <TagLink tag={tag} withWiki={false} withCount={false}/> | |||
| </td> | |||
| <td className="p-2"> | |||
| {editing[tag.id] | |||
| ? ( | |||
| <TextArea value={rawTags[tag.id]} onChange={ev => { | |||
| setRawTags (rawTags => ({ ...rawTags, [tag.id]: ev.target.value })) | |||
| }} />) | |||
| }}/>) | |||
| : tag.linkedTags.map((lt, j) => ( | |||
| <span key={j} className="mr-2"> | |||
| <TagLink tag={lt} | |||
| linkFlg={false} | |||
| withCount={false} /> | |||
| withCount={false}/> | |||
| </span>))} | |||
| </td> | |||
| {memberFlg && ( | |||
| @@ -55,7 +55,7 @@ export default ({ user, setUser }: Props) => { | |||
| return ( | |||
| <MainArea> | |||
| <Helmet> | |||
| <meta name="robots" content="noindex" /> | |||
| <meta name="robots" content="noindex"/> | |||
| <title>設定 | {SITE_TITLE}</title> | |||
| </Helmet> | |||
| @@ -71,7 +71,7 @@ export default ({ user, setUser }: Props) => { | |||
| className="w-full border rounded p-2" | |||
| value={name} | |||
| placeholder="名もなきニジラー" | |||
| onChange={ev => setName (ev.target.value)} /> | |||
| onChange={ev => setName (ev.target.value)}/> | |||
| {(user && !(user.name)) && ( | |||
| <p className="mt-1 text-sm text-red-500"> | |||
| 名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!! | |||
| @@ -104,10 +104,10 @@ export default ({ user, setUser }: Props) => { | |||
| <UserCodeDialogue visible={userCodeVsbl} | |||
| onVisibleChange={setUserCodeVsbl} | |||
| user={user} | |||
| setUser={setUser} /> | |||
| setUser={setUser}/> | |||
| <InheritDialogue visible={inheritVsbl} | |||
| onVisibleChange={setInheritVsbl} | |||
| setUser={setUser} /> | |||
| setUser={setUser}/> | |||
| </MainArea>) | |||
| } | |||
| @@ -36,9 +36,16 @@ export default () => { | |||
| if (/^\d+$/.test (title)) | |||
| { | |||
| void (async () => { | |||
| const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`) | |||
| const data = res.data as WikiPage | |||
| navigate (`/wiki/${ data.title }`, { replace: true }) | |||
| try | |||
| { | |||
| const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`) | |||
| const data = res.data as WikiPage | |||
| navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | |||
| } | |||
| catch | |||
| { | |||
| ; | |||
| } | |||
| }) () | |||
| return | |||
| @@ -51,6 +58,8 @@ export default () => { | |||
| `${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`, | |||
| { params: version ? { version } : { } }) | |||
| const data = toCamel (res.data as any, { deep: true }) as WikiPage | |||
| if (data.title !== title) | |||
| navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | |||
| setWikiPage (data) | |||
| WikiIdBus.set (data.id) | |||
| } | |||
| @@ -60,6 +69,7 @@ export default () => { | |||
| } | |||
| }) () | |||
| setPosts ([]) | |||
| void (async () => { | |||
| try | |||
| { | |||
| @@ -73,7 +83,7 @@ export default () => { | |||
| } | |||
| catch | |||
| { | |||
| setPosts ([]) | |||
| ; | |||
| } | |||
| }) () | |||
| @@ -97,6 +107,7 @@ export default () => { | |||
| <MainArea> | |||
| <Helmet> | |||
| <title>{`${ title } Wiki | ${ SITE_TITLE }`}</title> | |||
| {!(wikiPage?.body) && <meta name="robots" content="noindex"/>} | |||
| </Helmet> | |||
| {(wikiPage && version) && ( | |||
| @@ -118,18 +129,18 @@ export default () => { | |||
| <TagLink tag={tag} | |||
| withWiki={false} | |||
| withCount={false} | |||
| {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })} /> | |||
| {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> | |||
| </PageTitle> | |||
| <div className="prose mx-auto p-4"> | |||
| {wikiPage === undefined | |||
| ? 'Loading...' | |||
| : <WikiBody body={wikiPage?.body} title={title} />} | |||
| : <WikiBody title={title} body={wikiPage?.body}/>} | |||
| </div> | |||
| {(!(version) && posts.length > 0) && ( | |||
| <TabGroup> | |||
| <Tab name="広場"> | |||
| <PostList posts={posts} /> | |||
| <PostList posts={posts}/> | |||
| </Tab> | |||
| </TabGroup>)} | |||
| </MainArea>) | |||
| @@ -42,7 +42,7 @@ export default () => { | |||
| diff.diff.map (d => ( | |||
| <span className={cn (d.type === 'added' && 'bg-green-200 dark:bg-green-800', | |||
| d.type === 'removed' && 'bg-red-200 dark:bg-red-800')}> | |||
| {d.content == '\n' ? <br /> : d.content} | |||
| {d.content == '\n' ? <br/> : d.content} | |||
| </span>))) | |||
| : 'Loading...'} | |||
| </div> | |||
| @@ -21,7 +21,7 @@ type Props = { user: User | null } | |||
| export default ({ user }: Props) => { | |||
| if (!(['admin', 'member'].some (r => user?.role === r))) | |||
| return <Forbidden /> | |||
| return <Forbidden/> | |||
| const { id } = useParams () | |||
| @@ -73,7 +73,7 @@ export default ({ user }: Props) => { | |||
| <input type="text" | |||
| value={title} | |||
| onChange={e => setTitle (e.target.value)} | |||
| className="w-full border p-2 rounded" /> | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* 本文 */} | |||
| @@ -82,7 +82,7 @@ export default ({ user }: Props) => { | |||
| <MdEditor value={body} | |||
| style={{ height: '500px' }} | |||
| renderHTML={text => mdParser.render (text)} | |||
| onChange={({ text }) => setBody (text)} /> | |||
| onChange={({ text }) => setBody (text)}/> | |||
| </div> | |||
| {/* 送信 */} | |||
| @@ -27,54 +27,54 @@ export default () => { | |||
| return ( | |||
| <MainArea> | |||
| <Helmet> | |||
| <title>{`Wiki 変更履歴 | ${ SITE_TITLE }`}</title> | |||
| </Helmet> | |||
| <table className="table-auto w-full border-collapse"> | |||
| <thead> | |||
| <tr> | |||
| <th></th> | |||
| <th className="p-2 text-left">タイトル</th> | |||
| <th className="p-2 text-left">変更</th> | |||
| <th className="p-2 text-left">日時</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {changes.map (change => ( | |||
| <tr key={change.sha}> | |||
| <td> | |||
| {change.changeType === 'update' && ( | |||
| <Link to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.sha }`}> | |||
| 差分 | |||
| </Link>)} | |||
| </td> | |||
| <td className="p-2"> | |||
| <Link to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.sha }`}> | |||
| {change.wikiPage.title} | |||
| </Link> | |||
| </td> | |||
| <td className="p-2"> | |||
| {(() => { | |||
| switch (change.changeType) | |||
| { | |||
| case 'create': | |||
| return '新規' | |||
| case 'update': | |||
| return '更新' | |||
| case 'delete': | |||
| return '削除' | |||
| } | |||
| }) ()} | |||
| </td> | |||
| <td className="p-2"> | |||
| <Link to={`/users/${ change.user.id }`}> | |||
| {change.user.name} | |||
| </Link> | |||
| <br /> | |||
| {change.timestamp} | |||
| </td> | |||
| </tr>))} | |||
| </tbody> | |||
| </table> | |||
| <Helmet> | |||
| <title>{`Wiki 変更履歴 | ${ SITE_TITLE }`}</title> | |||
| </Helmet> | |||
| <table className="table-auto w-full border-collapse"> | |||
| <thead> | |||
| <tr> | |||
| <th></th> | |||
| <th className="p-2 text-left">タイトル</th> | |||
| <th className="p-2 text-left">変更</th> | |||
| <th className="p-2 text-left">日時</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {changes.map (change => ( | |||
| <tr key={change.sha}> | |||
| <td> | |||
| {change.changeType === 'update' && ( | |||
| <Link to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.sha }`}> | |||
| 差分 | |||
| </Link>)} | |||
| </td> | |||
| <td className="p-2"> | |||
| <Link to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.sha }`}> | |||
| {change.wikiPage.title} | |||
| </Link> | |||
| </td> | |||
| <td className="p-2"> | |||
| {(() => { | |||
| switch (change.changeType) | |||
| { | |||
| case 'create': | |||
| return '新規' | |||
| case 'update': | |||
| return '更新' | |||
| case 'delete': | |||
| return '削除' | |||
| } | |||
| }) ()} | |||
| </td> | |||
| <td className="p-2"> | |||
| <Link to={`/users/${ change.user.id }`}> | |||
| {change.user.name} | |||
| </Link> | |||
| <br/> | |||
| {change.timestamp} | |||
| </td> | |||
| </tr>))} | |||
| </tbody> | |||
| </table> | |||
| </MainArea>) | |||
| } | |||
| @@ -21,7 +21,7 @@ type Props = { user: User | null } | |||
| export default ({ user }: Props) => { | |||
| if (!(['admin', 'member'].some (r => user?.role === r))) | |||
| return <Forbidden /> | |||
| return <Forbidden/> | |||
| const location = useLocation () | |||
| const navigate = useNavigate () | |||
| @@ -67,7 +67,7 @@ export default ({ user }: Props) => { | |||
| <input type="text" | |||
| value={title} | |||
| onChange={e => setTitle (e.target.value)} | |||
| className="w-full border p-2 rounded" /> | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* 本文 */} | |||
| @@ -76,7 +76,7 @@ export default ({ user }: Props) => { | |||
| <MdEditor value={body} | |||
| style={{ height: '500px' }} | |||
| renderHTML={text => mdParser.render (text)} | |||
| onChange={({ text }) => setBody (text)} /> | |||
| onChange={({ text }) => setBody (text)}/> | |||
| </div> | |||
| {/* 送信 */} | |||
| @@ -1,5 +0,0 @@ | |||
| // import { Route, | |||
| // createBrowserRouter, | |||
| // createRoutesFromElements } from 'react-router-dom' | |||
| // | |||
| // import App from '@/App' | |||
| @@ -1,7 +1,7 @@ | |||
| import React from 'react' | |||
| import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' | |||
| import type { ReactNode } from 'react' | |||
| export type Category = typeof CATEGORIES[number] | |||
| export type Menu = MenuItem[] | |||
| @@ -17,28 +17,38 @@ export type NicoTag = Tag & { | |||
| linkedTags: Tag[] } | |||
| export type Post = { | |||
| id: number | |||
| url: string | |||
| title: string | |||
| thumbnail: string | |||
| thumbnailBase: string | |||
| tags: Tag[] | |||
| viewed: boolean | |||
| related: Post[] } | |||
| export type SubMenuItem = { | |||
| component: React.ReactNode | |||
| visible: boolean | |||
| } | { | |||
| name: string | |||
| to: string | |||
| visible?: boolean } | |||
| id: number | |||
| url: string | |||
| title: string | |||
| thumbnail: string | |||
| thumbnailBase: string | |||
| tags: Tag[] | |||
| viewed: boolean | |||
| related: Post[] | |||
| createdAt: string | |||
| originalCreatedFrom: string | null | |||
| originalCreatedBefore: string | null } | |||
| export type PostTagChange = { | |||
| post: Post | |||
| tag: Tag | |||
| user?: User | |||
| changeType: 'add' | 'remove' | |||
| timestamp: string } | |||
| export type SubMenuItem = | |||
| | { component: ReactNode | |||
| visible: boolean } | |||
| | { name: string | |||
| to: string | |||
| visible?: boolean } | |||
| export type Tag = { | |||
| id: number | |||
| name: string | |||
| category: Category | |||
| postCount: number } | |||
| postCount: number | |||
| children?: Tag[] } | |||
| export type User = { | |||
| id: number | |||
| @@ -2,35 +2,26 @@ | |||
| import type { Config } from 'tailwindcss' | |||
| import { DARK_COLOUR_SHADE, | |||
| LIGHT_COLOUR_SHADE, | |||
| TAG_COLOUR } from './src/consts' | |||
| LIGHT_COLOUR_SHADE, | |||
| TAG_COLOUR } from './src/consts' | |||
| const colours = Object.values (TAG_COLOUR) | |||
| export default { | |||
| content: ['./src/**/*.{html,js,ts,jsx,tsx}'], | |||
| safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`), | |||
| ...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`), | |||
| ...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`), | |||
| ...colours.map (c => `dark:hover:text-${ c }-${ DARK_COLOUR_SHADE - 200 }`)], | |||
| theme: { | |||
| extend: { | |||
| animation: { | |||
| 'rainbow-scroll': 'rainbow-scroll .25s linear infinite', | |||
| }, | |||
| colors: { | |||
| red: { | |||
| 925: '#5f1414', | |||
| 975: '#230505', | |||
| } | |||
| }, | |||
| keyframes: { | |||
| 'rainbow-scroll': { | |||
| '0%': { backgroundPosition: '0% 50%' }, | |||
| '100%': { backgroundPosition: '200% 50%' }, | |||
| }, | |||
| }, | |||
| } | |||
| }, | |||
| plugins: [], | |||
| } satisfies Config | |||
| content: ['./src/**/*.{html,js,ts,jsx,tsx}'], | |||
| safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`), | |||
| ...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`), | |||
| ...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`), | |||
| ...colours.map (c => `dark:hover:text-${ c }-${ DARK_COLOUR_SHADE - 200 }`)], | |||
| theme: { | |||
| extend: { | |||
| animation: { | |||
| 'rainbow-scroll': 'rainbow-scroll .25s linear infinite' }, | |||
| colors: { | |||
| red: { 925: '#5f1414', | |||
| 975: '#230505' } }, | |||
| keyframes: { | |||
| 'rainbow-scroll': { | |||
| '0%': { backgroundPosition: '0% 50%' }, | |||
| '100%': { backgroundPosition: '200% 50%' } } } } }, | |||
| plugins: [] } satisfies Config | |||