Merge remote-tracking branch 'origin/main' into #92

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