コミットを比較

..

24 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 8ac56f74ce #34 2026-07-06 22:02:25 +09:00
みてるぞ 59a87c0e11 #34 2026-07-06 21:17:39 +09:00
みてるぞ f66532a55d #34 2026-07-06 21:12:49 +09:00
みてるぞ c3d2d8e1d1 #34 2026-07-06 20:46:16 +09:00
みてるぞ f4dea9b91e #34 2026-07-06 19:34:11 +09:00
みてるぞ f00e97e335 #34 2026-07-05 20:18:36 +09:00
みてるぞ a0c1b8ba31 #34 2026-07-05 20:18:16 +09:00
みてるぞ 021d13a262 #34 2026-07-05 20:00:58 +09:00
みてるぞ 1b6cac43c2 #34 2026-07-05 18:21:39 +09:00
みてるぞ b0cce4b7ee #34 2026-07-05 12:51:31 +09:00
みてるぞ 40cc3b2c0c Merge remote-tracking branch 'origin/main' into feature/034 2026-07-05 11:59:28 +09:00
みてるぞ 61ab4744bd #34 2026-07-05 11:57:29 +09:00
みてるぞ ca92c62049 #34 2026-07-05 11:27:45 +09:00
みてるぞ 4e3fb6bef7 #34 2026-07-05 03:43:37 +09:00
みてるぞ 3a9ac8af32 #34 2026-07-05 03:27:58 +09:00
みてるぞ 7bc9ee1e68 #34 2026-07-05 03:19:58 +09:00
みてるぞ 95b4db1fc2 #34 2026-07-05 02:59:48 +09:00
みてるぞ 4117bdd31e #34 2026-07-05 02:40:19 +09:00
みてるぞ fecbb4b354 #34 2026-07-05 02:04:07 +09:00
みてるぞ d0ea329887 #34 2026-07-05 01:51:07 +09:00
みてるぞ 3b29c3e10e #34 2026-07-05 00:03:13 +09:00
みてるぞ 518c5fa0f2 #34 2026-07-04 21:47:16 +09:00
みてるぞ 4cc2dc5441 #34 2026-07-04 19:50:40 +09:00
みてるぞ 6a57dc53bf #34 2026-07-04 19:03:50 +09:00
56個のファイルの変更1503行の追加1895行の削除
+8 -10
ファイルの表示
@@ -89,13 +89,15 @@ class MaterialsController < ApplicationController
begin
Material.transaction do
tag = resolve_material_tag!(tag_name_raw)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(uploaded_blob) if uploaded_blob
material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: current_user)
@@ -137,7 +139,10 @@ class MaterialsController < ApplicationController
begin
Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
tag = resolve_material_tag!(tag_name_raw)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob
material.file.attach(uploaded_blob)
@@ -145,7 +150,6 @@ class MaterialsController < ApplicationController
material.file.detach
end
material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user)
@@ -236,12 +240,6 @@ class MaterialsController < ApplicationController
nil
end
def resolve_material_tag! tag_name_raw
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
end
def material_index_needs_tag_name? filters
filters[:q].present? || filters[:sort] == 'tag_name'
end
+37 -44
ファイルの表示
@@ -1,57 +1,50 @@
class PreviewController < ApplicationController
before_action :require_member!
def title
return render_bad_request('URL は必須です.') if params[:url].blank?
# TODO: # 既知サイトなら決まったフォーマットで title 取得するやぅに.
return head :unauthorized unless current_user
render json: { title: Preview::ThumbnailFetcher.title(params[:url]) }
rescue Preview::UrlSafety::UnsafeUrl => e
url = params[:url]
return render_bad_request('URL は必須です.') unless url.present?
unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url
end
html = URI.open(url, open_timeout: 5, read_timeout: 5).read
doc = Nokogiri::HTML.parse(html)
title = doc.at('title')&.text&.strip
render json: { title: title }
rescue => e
render_bad_request(e.message)
rescue Preview::HttpFetcher::FetchTimeout => e
render_preview_error(e.message, :gateway_timeout)
rescue Preview::HttpFetcher::ResponseTooLarge => e
render_preview_error(e.message, :payload_too_large)
rescue Preview::HttpFetcher::FetchFailed => e
render_preview_error(e.message, :bad_gateway)
end
def thumbnail
return render_bad_request('URL は必須です.') if params[:url].blank?
# TODO: 既知ドメインであれば指定のアドレスからサムネールを取得するやぅにする.
image = MiniMagick::Image.read(Preview::ThumbnailFetcher.fetch(params[:url]))
image.auto_orient
image.resize '180x180>'
image.format 'png'
width, height = image.dimensions
raise Preview::ThumbnailFetcher::GenerationFailed, 'サムネール画像の変換に失敗しました.' if width > 180 || height > 180
send_data image.to_blob, type: 'image/png', disposition: 'inline'
rescue Preview::UrlSafety::UnsafeUrl => e
render_bad_request(e.message)
rescue Preview::HttpFetcher::FetchTimeout => e
render_preview_error(e.message, :gateway_timeout)
rescue Preview::HttpFetcher::ResponseTooLarge => e
render_preview_error(e.message, :payload_too_large)
rescue Preview::HttpFetcher::FetchFailed => e
render_preview_error(e.message, :bad_gateway)
rescue Preview::ThumbnailFetcher::GenerationFailed, MiniMagick::Error => e
render_unprocessable_entity(e.message)
end
private
def require_member!
return head :unauthorized unless current_user
return if current_user.gte_member?
head :forbidden
end
url = params[:url]
return render_bad_request('URL は必須です.') if url.blank?
def render_preview_error(message, status)
render json: { type: status.to_s,
message:,
errors: { },
base_errors: [message] },
status:
unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url
end
path = Rails.root.join('tmp', "thumb_#{ SecureRandom.hex }.png")
system("node #{ Rails.root }/lib/screenshot.js #{ Shellwords.escape(url) } #{ path }")
if File.exist?(path)
image = MiniMagick::Image.open(path)
image.resize '180x180'
File.delete(path) rescue nil
send_file image.path, type: 'image/png', disposition: 'inline'
else
render json: { type: 'internal_server_error',
message: 'サムネールを生成できませんでした.',
errors: { },
base_errors: ['サムネールを生成できませんでした.'] },
status: :internal_server_error
end
end
end
-21
ファイルの表示
@@ -1,21 +0,0 @@
module Preview
class HtmlMetadataExtractor
IMAGE_SELECTORS = [
'meta[property="og:image"]',
'meta[name="twitter:image"]',
'meta[name="thumbnail"]'
].freeze
def self.extract(response)
document = Nokogiri::HTML.parse(response.body)
image_url = IMAGE_SELECTORS.filter_map {
document.at_css(_1)&.[]('content')&.strip.presence
}.first
{ title: document.at_css('title')&.text&.strip,
image_url: image_url ? URI.join(response.url, image_url).to_s : nil }
rescue URI::InvalidURIError
{ title: document.at_css('title')&.text&.strip, image_url: nil }
end
end
end
-122
ファイルの表示
@@ -1,122 +0,0 @@
require 'net/http'
require 'json'
module Preview
class HttpFetcher
class FetchFailed < StandardError; end
class FetchTimeout < FetchFailed; end
class ResponseTooLarge < FetchFailed; end
MAX_REDIRECTS = 5
DEFAULT_MAX_BYTES = 5.megabytes
Response = Data.define(:body, :content_type, :url)
def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS)
uri, addresses = UrlSafety.validate(raw_url)
response = request(uri, addresses.first, max_bytes)
if response.is_a?(Net::HTTPRedirection)
location = response['location']
if redirects.zero?
log_failure(:redirect_limit,
url: uri.to_s,
redirects:,
location:,
content_type: response['content-type'],
content_length: response['content-length'])
raise FetchFailed, 'redirect が多すぎます.'
end
if location.blank?
log_failure(:blank_redirect_location,
url: uri.to_s,
redirects:,
content_type: response['content-type'],
content_length: response['content-length'])
raise FetchFailed, 'redirect 先が不正です.'
end
redirect_url =
begin
URI.join(uri, location).to_s
rescue URI::InvalidURIError => e
log_failure(:invalid_redirect_location,
url: uri.to_s,
redirects:,
location:,
error: e.class.name,
message: e.message)
raise FetchFailed, 'redirect 先が不正です.'
end
return fetch(redirect_url, max_bytes:, redirects: redirects - 1)
end
unless response.is_a?(Net::HTTPSuccess)
log_failure(:http_status,
url: uri.to_s,
code: response.code,
content_type: response['content-type'],
content_length: response['content-length'])
raise FetchFailed, "外部サーバーが HTTP #{ response.code } を返しました."
end
Response.new(response.body, response['content-type'].to_s, uri.to_s)
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error => e
log_failure(:timeout, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
raise FetchTimeout, e.message
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
log_failure(:network_error, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
raise FetchFailed, e.message
end
def self.request(uri, ip_address, max_bytes)
http = Net::HTTP.new(uri.host, uri.port)
http.ipaddr = ip_address
http.use_ssl = uri.scheme == 'https'
http.open_timeout = 5
http.read_timeout = 8
http.write_timeout = 5
request = Net::HTTP::Get.new(uri)
request['User-Agent'] = 'BTRC-Hub thumbnail preview'
request['Accept'] = 'text/html,image/*;q=0.9,*/*;q=0.1'
http.request(request) do |response|
length = response['content-length'].to_i
if length > max_bytes
log_failure(:response_too_large,
url: uri.to_s,
content_type: response['content-type'],
content_length: response['content-length'],
max_bytes:)
raise ResponseTooLarge, '外部データが大きすぎます.'
end
body = +''
response.read_body do |chunk|
body << chunk
next unless body.bytesize > max_bytes
log_failure(:response_too_large,
url: uri.to_s,
content_type: response['content-type'],
content_length: response['content-length'],
bytes_read: body.bytesize,
max_bytes:)
raise ResponseTooLarge, '外部データが大きすぎます.'
end
response.instance_variable_set(:@body, body)
response.instance_variable_set(:@read, true)
return response
end
end
def self.log_failure(reason, **payload)
Rails.logger.warn("preview_http_fetcher_failure #{ { reason:, **payload }.to_json }")
end
private_class_method :request, :log_failure
end
end
-31
ファイルの表示
@@ -1,31 +0,0 @@
module Preview
class KnownSiteExtractor
def self.thumbnail_url(uri)
youtube_thumbnail(uri)
end
def self.niconico_video_id(uri)
case uri.host&.downcase
when 'www.nicovideo.jp', 'nicovideo.jp'
uri.path[%r{\A/watch/(sm\d+)\z}, 1]
when 'nico.ms'
uri.path[%r{\A/(sm\d+)\z}, 1]
end
end
def self.youtube_thumbnail(uri)
id =
case uri.host&.downcase
when 'youtu.be'
uri.path.split('/').reject(&:blank?).first
when 'www.youtube.com', 'youtube.com', 'm.youtube.com'
uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil
end
return unless id&.match?(/\A[A-Za-z0-9_-]{6,20}\z/)
"https://i.ytimg.com/vi/#{ id }/hqdefault.jpg"
end
private_class_method :youtube_thumbnail
end
end
-95
ファイルの表示
@@ -1,95 +0,0 @@
module Preview
class ThumbnailFetcher
class GenerationFailed < StandardError; end
ALLOWED_IMAGE_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
].freeze
HTML_MAX_BYTES = 1.megabyte
NICONICO_XML_MAX_BYTES = 256.kilobytes
def self.fetch(raw_url)
uri, = UrlSafety.validate(raw_url)
known_url = KnownSiteExtractor.thumbnail_url(uri)
image = fetch_image_or_nil(known_url) if known_url
return image if image
niconico_url = niconico_thumbnail_url(uri)
image = fetch_image_or_nil(niconico_url) if niconico_url
return image if image
page = HttpFetcher.fetch(uri.to_s, max_bytes: HTML_MAX_BYTES)
metadata = HtmlMetadataExtractor.extract(page)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.' if metadata[:image_url].blank?
fetch_image!(metadata[:image_url])
end
def self.title(raw_url)
uri, = UrlSafety.validate(raw_url)
HtmlMetadataExtractor.extract(
HttpFetcher.fetch(uri.to_s, max_bytes: HTML_MAX_BYTES))[:title]
end
def self.fetch_image_or_nil(url)
return nil if url.blank?
response = HttpFetcher.fetch(url)
return nil unless allowed_image_content_type?(response.content_type)
response.body
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::FetchFailed
nil
end
def self.fetch_image!(url)
response = HttpFetcher.fetch(url)
unless allowed_image_content_type?(response.content_type)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end
response.body
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::ResponseTooLarge
raise
rescue HttpFetcher::FetchFailed
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
end
def self.niconico_thumbnail_url(uri)
video_id = KnownSiteExtractor.niconico_video_id(uri)
return nil if video_id.blank?
response = HttpFetcher.fetch("https://ext.nicovideo.jp/api/getthumbinfo/#{ video_id }",
max_bytes: NICONICO_XML_MAX_BYTES)
xml = Nokogiri::XML(response.body)
return nil unless xml.at_xpath('/nicovideo_thumb_response/@status')&.value == 'ok'
xml.at_xpath('//thumbnail_url')&.text&.strip.presence
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }.to_json }")
nil
rescue Nokogiri::XML::SyntaxError => e
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }.to_json }")
nil
end
def self.allowed_image_content_type?(content_type)
mime_type = content_type.to_s.split(';', 2).first.downcase.strip
ALLOWED_IMAGE_CONTENT_TYPES.include?(mime_type)
end
private_class_method :fetch_image_or_nil, :fetch_image!,
:niconico_thumbnail_url,
:allowed_image_content_type?
end
end
-55
ファイルの表示
@@ -1,55 +0,0 @@
require 'resolv'
require 'ipaddr'
require 'uri'
module Preview
class UrlSafety
class UnsafeUrl < StandardError; end
FORBIDDEN_NETWORKS = [
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8',
'169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24',
'192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15',
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4',
'240.0.0.0/4', '::/128', '::1/128', 'fc00::/7', 'fe80::/10',
'ff00::/8', '2001:db8::/32', '::ffff:0:0/96'
].map { IPAddr.new(_1) }.freeze
def self.validate(raw_url)
value = raw_url.to_s.strip
if value.match?(/\A[a-z][a-z0-9+\-.]*:/i)
unless value.match?(/\Ahttps?:\/\//i)
raise UnsafeUrl, 'http または https の URL を指定してください.'
end
else
value = "http://#{ value }"
end
uri = URI.parse(value)
unless ['http', 'https'].include?(uri.scheme&.downcase) && uri.host.present?
raise UnsafeUrl, 'http または https の URL を指定してください.'
end
raise UnsafeUrl, 'userinfo つき URL は使用できません.' if uri.userinfo.present?
addresses = Resolv.getaddresses(uri.host)
raise UnsafeUrl, 'URL のホストを解決できません.' if addresses.empty?
parsed_addresses = addresses.map { IPAddr.new(_1) }
if parsed_addresses.any? { |address| forbidden?(address) }
raise UnsafeUrl, '安全でない接続先は使用できません.'
end
[uri, parsed_addresses.map(&:to_s)]
rescue Resolv::ResolvError
raise UnsafeUrl, 'URL のホストを解決できません.'
rescue URI::InvalidURIError, IPAddr::InvalidAddressError
raise UnsafeUrl, 'URL が不正です.'
end
def self.forbidden?(address)
FORBIDDEN_NETWORKS.any? { _1.include?(address) }
end
private_class_method :forbidden?
end
end
生成ファイル
+6 -33
ファイルの表示
@@ -72,7 +72,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id"
t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
t.index ["user_id"], name: "index_gekanator_games_on_user_id"
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
end
create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -275,10 +274,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at"
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at"
t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -287,14 +285,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
end
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "target_post_id", null: false
t.float "cos", null: false
t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc }
t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos"
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end
@@ -350,8 +347,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["post_id"], name: "index_post_versions_on_post_id"
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -369,7 +364,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["url"], name: "index_posts_on_url", unique: true
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -383,19 +377,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
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
@@ -431,7 +412,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.bigint "tag_id", null: false
t.bigint "target_tag_id", null: false
t.float "cos", null: false
t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc }
t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos"
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
end
@@ -447,10 +428,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at"
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at"
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -466,7 +446,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -620,13 +599,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -671,8 +648,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
@@ -754,8 +729,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
+1102
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読込み
+15
ファイルの表示
@@ -0,0 +1,15 @@
{
"name": "lib",
"version": "1.0.0",
"main": "screenshot.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"puppeteer": "^24.10.0"
}
}
+18
ファイルの表示
@@ -0,0 +1,18 @@
const puppeteer = require ('puppeteer')
const fs = require ('fs')
void (async () => {
const url = process.argv[2]
const output = process.argv[3]
const browser = await puppeteer.launch ({
args: ['--no-sandbox', '--disable-setuid-sandbox'] })
const page = await browser.newPage ()
await page.setViewport ({ width: 960, height: 960 })
await page.goto (url, { waitUntil: 'networkidle2', timeout: 15000 })
await page.screenshot ({ path: output })
await browser.close ()
}) ()
-34
ファイルの表示
@@ -1,34 +0,0 @@
require 'rails_helper'
RSpec.describe Setting, type: :model do
it 'accepts the default typed user settings' do
setting = described_class.new({ user: create(:user) }.merge(described_class.defaults))
expect(setting).to be_valid
end
it 'requires one settings row per user' do
user = create(:user)
described_class.create!({ user: }.merge(described_class.defaults))
duplicate = described_class.new({ user: }.merge(described_class.defaults))
expect(duplicate).not_to be_valid
expect(duplicate.errors[:user_id]).to be_present
end
it 'validates enum-like settings columns' do
setting = described_class.new(
{ user: create(:user),
theme: 'neon',
auto_fetch_title: 'sometimes',
auto_fetch_thumbnail: 'sometimes',
wiki_editor_mode: 'sideways' })
expect(setting).not_to be_valid
expect(setting.errors[:theme]).to be_present
expect(setting.errors[:auto_fetch_title]).to be_present
expect(setting.errors[:auto_fetch_thumbnail]).to be_present
expect(setting.errors[:wiki_editor_mode]).to be_present
end
end
-40
ファイルの表示
@@ -1,40 +0,0 @@
require 'rails_helper'
RSpec.describe UserThemeSlot, type: :model do
it 'accepts a theme slot for one user, base theme, and slot number' do
slot = described_class.new(user: create(:user),
base_theme: 'light',
slot_no: 1,
tokens: { 'background' => '0 0% 100%' })
expect(slot).to be_valid
end
it 'requires unique slot numbers per user and base theme' do
user = create(:user)
described_class.create!(user:,
base_theme: 'dark',
slot_no: 2,
tokens: { 'background' => '222.2 84% 4.9%' })
duplicate = described_class.new(user:,
base_theme: 'dark',
slot_no: 2,
tokens: { 'background' => '0 0% 100%' })
expect(duplicate).not_to be_valid
expect(duplicate.errors[:user_id]).to be_present
end
it 'validates base theme, slot number, and token object shape' do
slot = described_class.new(user: create(:user),
base_theme: 'system',
slot_no: 4,
tokens: 'not-object')
expect(slot).not_to be_valid
expect(slot.errors[:base_theme]).to be_present
expect(slot.errors[:slot_no]).to be_present
expect(slot.errors[:tokens]).to be_present
end
end
-89
ファイルの表示
@@ -287,25 +287,6 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
end
it 'creates a create tag_version for a newly created material tag' do
expect do
post '/materials', params: {
tag: 'material_create_versioned_tag',
file: dummy_upload(filename: 'created.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:created)
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_create_versioned_tag' })
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_create_versioned_tag')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'snapshots attached file metadata and sha256' do
post '/materials', params: {
tag: 'material_create_file_version',
@@ -485,73 +466,6 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('tag', 'name')).to eq('material_update_new')
end
it 'creates a create tag_version when update creates a new tag' do
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_versioned_tag',
file: dummy_upload(filename: 'updated.png')
}
end.to change(Tag, :count).by(1)
.and change(TagName, :count).by(1)
.and change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_update_versioned_tag' })
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_versioned_tag')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'backfills a create tag_version for an existing material tag without history' do
existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'),
category: :material)
expect(existing_tag.tag_versions).to be_empty
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_existing_no_history',
file: dummy_upload(filename: 'updated.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = existing_tag.reload.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_existing_no_history')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'backfills a create tag_version for an existing character tag without history' do
existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'),
category: :character)
expect(existing_tag.tag_versions).to be_empty
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_character_no_history',
file: dummy_upload(filename: 'updated.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = existing_tag.reload.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_character_no_history')
expect(version.category).to eq('character')
expect(version.created_by_user).to eq(member_user)
end
it 'detaches the existing file without purging blob when url replaces file' do
old_blob_id = material.file.blob.id
@@ -580,7 +494,6 @@ RSpec.describe 'Materials API', type: :request do
it 'does not increase version for the same snapshot update' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
TagVersioning.ensure_snapshot!(tag, created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
@@ -588,8 +501,6 @@ RSpec.describe 'Materials API', type: :request do
}
end.not_to change(MaterialVersion, :count)
expect(tag.reload.tag_versions.count).to eq(1)
expect(response).to have_http_status(:ok)
expect(material.reload.version_no).to eq(1)
end
+14 -32
ファイルの表示
@@ -1,46 +1,28 @@
require 'rails_helper'
require "rails_helper"
RSpec.describe 'Preview', type: :request do
describe 'GET /preview/title' do
it '401 unless logged in' do
RSpec.describe "Preview", type: :request do
describe "GET /preview/title" do
it "401 unless logged in" do
sign_out
get '/preview/title', params: { url: 'example.com' }
get "/preview/title", params: { url: "example.com" }
expect(response).to have_http_status(:unauthorized)
end
it '403 when logged in as guest' do
sign_in_as(create(:user, :guest))
get '/preview/title', params: { url: 'example.com' }
expect(response).to have_http_status(:forbidden)
end
it '400 when url blank' do
sign_in_as(create(:user, :member))
get '/preview/title', params: { url: '' }
it "400 when url blank" do
sign_in_as(create(:user))
get "/preview/title", params: { url: "" }
expect(response).to have_http_status(:bad_request)
end
it 'returns parsed title' do
sign_in_as(create(:user, :member))
allow(Preview::ThumbnailFetcher)
.to receive(:title)
.with('example.com')
.and_return('Hello')
it "returns parsed title (stubbing URI.open)" do
sign_in_as(create(:user))
fake_html = "<html><head><title> Hello </title></head></html>"
allow(URI).to receive(:open).and_return(StringIO.new(fake_html))
get '/preview/title', params: { url: 'example.com' }
get "/preview/title", params: { url: "example.com" }
expect(response).to have_http_status(:ok)
expect(json['title']).to eq('Hello')
end
it '413 when fetched response is too large' do
sign_in_as(create(:user, :member))
allow(Preview::ThumbnailFetcher)
.to receive(:title)
.and_raise(Preview::HttpFetcher::ResponseTooLarge, '外部データが大きすぎます.')
get '/preview/title', params: { url: 'example.com' }
expect(response).to have_http_status(:payload_too_large)
expect(json["title"]).to eq("Hello")
end
end
end
-98
ファイルの表示
@@ -1,98 +0,0 @@
require 'rails_helper'
RSpec.describe 'user settings', type: :request do
describe 'GET /users/settings' do
it 'requires a current user' do
sign_out
get '/users/settings'
expect(response).to have_http_status(:unauthorized)
end
it 'returns defaults and creates a missing settings row' do
user = create(:user)
sign_in_as(user)
expect {
get '/users/settings'
}.to change(Setting, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json).to eq(
'theme' => 'system',
'auto_fetch_title' => 'manual',
'auto_fetch_thumbnail' => 'manual',
'wiki_editor_mode' => 'split')
expect(user.reload.setting).to be_present
end
end
describe 'PATCH /users/settings' do
it 'requires a current user' do
sign_out
patch '/users/settings', params: { theme: 'dark' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'updates multiple typed settings at once' do
user = create(:user)
sign_in_as(user)
patch '/users/settings',
params: {
theme: 'dark',
auto_fetch_title: 'auto',
auto_fetch_thumbnail: 'off',
wiki_editor_mode: 'preview' },
as: :json
expect(response).to have_http_status(:ok)
expect(json).to include(
'theme' => 'dark',
'auto_fetch_title' => 'auto',
'auto_fetch_thumbnail' => 'off',
'wiki_editor_mode' => 'preview')
expect(user.reload.setting).to have_attributes(
theme: 'dark',
auto_fetch_title: 'auto',
auto_fetch_thumbnail: 'off',
wiki_editor_mode: 'preview')
end
it 'does not treat Rails parameter wrapping as an editable setting' do
user = create(:user)
sign_in_as(user)
patch '/users/settings',
params: { theme: 'light', user_setting: { theme: 'dark' } },
as: :json
expect(response).to have_http_status(:ok)
expect(user.reload.setting.theme).to eq('light')
end
it 'returns validation_error for type mismatches' do
sign_in_as(create(:user))
patch '/users/settings', params: { theme: 1 }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(json.fetch('errors')).to include(
'theme' => ['値の型が不正です.'])
end
it 'returns validation_error for invalid enum values' do
sign_in_as(create(:user))
patch '/users/settings', params: { theme: 'neon' }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(json.fetch('errors').fetch('theme')).to be_present
end
end
end
-104
ファイルの表示
@@ -1,104 +0,0 @@
require 'rails_helper'
RSpec.describe 'user theme slots', type: :request do
describe 'GET /users/theme_slots' do
it 'requires a current user' do
sign_out
get '/users/theme_slots'
expect(response).to have_http_status(:unauthorized)
end
it 'returns only the current user slots in stable order' do
user = create(:user)
other_user = create(:user)
sign_in_as(user)
UserThemeSlot.create!(user:, base_theme: 'light', slot_no: 2, tokens: { 'a' => 1 })
UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 1, tokens: { 'b' => 2 })
UserThemeSlot.create!(user: other_user,
base_theme: 'dark',
slot_no: 3,
tokens: { 'c' => 3 })
get '/users/theme_slots'
expect(response).to have_http_status(:ok)
expect(json.map { |slot| [slot.fetch('base_theme'), slot.fetch('slot_no')] }).to eq(
[['dark', 1], ['light', 2]])
expect(json.map { |slot| slot.fetch('tokens') }).to eq(
[{ 'b' => 2 }, { 'a' => 1 }])
end
end
describe 'PUT /users/theme_slots/:base_theme/:slot_no' do
it 'requires a current user' do
sign_out
put '/users/theme_slots/light/1',
params: { tokens: { background: '0 0% 100%' } },
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'creates a theme slot' do
user = create(:user)
sign_in_as(user)
expect {
put '/users/theme_slots/light/1',
params: { tokens: { background: '0 0% 100%', tagColours: { nico: '#ffffff' } } },
as: :json
}.to change(UserThemeSlot, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json).to include('base_theme' => 'light', 'slot_no' => 1)
expect(json.fetch('tokens')).to include(
'background' => '0 0% 100%',
'tagColours' => { 'nico' => '#ffffff' })
expect(user.theme_slots.find_by!(base_theme: 'light', slot_no: 1).tokens).to include(
'background' => '0 0% 100%')
end
it 'updates an existing theme slot instead of creating a duplicate' do
user = create(:user)
sign_in_as(user)
UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 2, tokens: { 'old' => true })
expect {
put '/users/theme_slots/dark/2',
params: { tokens: { background: '222.2 84% 4.9%' } },
as: :json
}.not_to change(UserThemeSlot, :count)
expect(response).to have_http_status(:ok)
expect(user.theme_slots.find_by!(base_theme: 'dark', slot_no: 2).tokens).to eq(
'background' => '222.2 84% 4.9%')
end
it 'rejects invalid path parameters and token shapes' do
sign_in_as(create(:user))
put '/users/theme_slots/system/4',
params: { tokens: 'not-object' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'base_theme' => ['値が不正です.'])
end
it 'rejects non-object tokens for valid slots' do
sign_in_as(create(:user))
put '/users/theme_slots/light/1',
params: { tokens: 'not-object' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tokens' => ['JSON object で指定してください.'])
end
end
end
-23
ファイルの表示
@@ -1,23 +0,0 @@
require 'rails_helper'
RSpec.describe Preview::HttpFetcher do
describe '.fetch' do
it 'raises FetchFailed when redirect location is invalid' do
redirect = Net::HTTPFound.new('1.1', '302', 'Found')
redirect['location'] = 'http://[invalid'
allow(Preview::UrlSafety).to receive(:validate)
.with('https://example.com/page')
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(described_class).to receive(:request)
.and_return(redirect)
allow(Rails.logger).to receive(:warn)
expect {
described_class.fetch('https://example.com/page')
}.to raise_error(Preview::HttpFetcher::FetchFailed)
expect(Rails.logger).to have_received(:warn)
.with(/invalid_redirect_location/)
end
end
end
-51
ファイルの表示
@@ -1,51 +0,0 @@
require 'rails_helper'
RSpec.describe Preview::ThumbnailFetcher do
describe '.fetch' do
it 'rejects svg thumbnails' do
page = Preview::HttpFetcher::Response.new(
'<meta property="og:image" content="https://example.com/thumb.svg">',
'text/html',
'https://example.com/page')
svg = Preview::HttpFetcher::Response.new(
'<svg></svg>',
'image/svg+xml',
'https://example.com/thumb.svg')
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/thumb.svg')
.and_return(svg)
expect {
described_class.fetch('https://example.com/page')
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
end
it 'accepts allowed image content type with parameters' do
page = Preview::HttpFetcher::Response.new(
'<meta property="og:image" content="https://example.com/thumb.jpg">',
'text/html',
'https://example.com/page')
image = Preview::HttpFetcher::Response.new(
'jpeg-bytes',
'image/jpeg; charset=binary',
'https://example.com/thumb.jpg')
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/thumb.jpg')
.and_return(image)
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
end
end
end
-15
ファイルの表示
@@ -1,15 +0,0 @@
require 'rails_helper'
RSpec.describe Preview::UrlSafety do
describe '.validate' do
it 'raises UnsafeUrl when DNS resolution fails' do
allow(Resolv).to receive(:getaddresses)
.with('missing.example')
.and_raise(Resolv::ResolvError)
expect {
described_class.validate('https://missing.example')
}.to raise_error(Preview::UrlSafety::UnsafeUrl)
end
end
end
+6 -56
ファイルの表示
@@ -1,5 +1,5 @@
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { BrowserRouter,
Navigate,
Route,
@@ -12,9 +12,7 @@ import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import { applyClientAnimationMode,
applyClientAppearance,
type ClientAnimationMode,
import { applyClientAppearance,
fetchUserThemeSlots,
fetchUserSettings,
getClientThemeMode,
@@ -62,51 +60,11 @@ import type { Dispatch, FC, SetStateAction } from 'react'
import type { User } from '@/types'
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation ()
if (animationMode === 'off')
{
return (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
}
return (
<AnimatePresence mode="wait">
<Routes location={location}>
@@ -159,7 +117,6 @@ const App: FC = () => {
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
const appLayoutTransition = useMemo (
() => (
@@ -185,10 +142,6 @@ const App: FC = () => {
return () => mediaQuery.removeEventListener ('change', handleThemeChange)
}, [])
useEffect (() => {
applyClientAnimationMode (animationMode)
}, [animationMode])
useEffect (() => {
const createUser = async () => {
const data = await apiPost<{ code: string; user: User }> ('/users')
@@ -269,18 +222,15 @@ const App: FC = () => {
? 'user'
: 'always'
}>
<LayoutWrapper>
<LayoutGroup>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</LayoutGroup>
</MotionConfig>
<Toaster/>
-75
ファイルの表示
@@ -1,75 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const dndKit = vi.hoisted (() => ({
useDraggable: vi.fn (),
useDroppable: vi.fn (),
}))
vi.mock ('@dnd-kit/core', () => dndKit)
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
const renderRow = (activeDndId?: string) => {
renderWithProviders (
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
nestLevel={2}
pathKey="cat-general-7"
suppressClickRef={{ current: false }}/>,
)
}
const tagBody = (): HTMLElement => {
const link = document.querySelector<HTMLElement> ('a[title="ドラッグ元"]')
if (link == null)
throw new Error ('tag link not found')
const body = link.closest ('div')
if (body == null)
throw new Error ('tag body not found')
return body
}
describe ('DraggableDroppableTagRow', () => {
beforeEach (() => {
vi.clearAllMocks ()
dndKit.useDraggable.mockReturnValue ({
attributes: { 'aria-describedby': 'drag-source' },
listeners: { onPointerDown: vi.fn () },
setNodeRef: vi.fn (),
transform: null })
dndKit.useDroppable.mockReturnValue ({
isOver: false,
setNodeRef: vi.fn () })
})
it ('passes dndId through draggable data for active-row tracking', () => {
renderRow ()
expect (dndKit.useDraggable).toHaveBeenCalledWith (
expect.objectContaining ({
id: 'tag-node:cat-general-7',
data: expect.objectContaining ({
dndId: 'tag-node:cat-general-7',
nestLevel: 2,
tagId: 7 }) }))
})
it ('hides only the active drag source from the explicit active dnd id', () => {
renderRow ('tag-node:cat-general-7')
expect (tagBody ()).toHaveStyle ({ visibility: 'hidden' })
})
it ('keeps inactive tag rows visible while another tag is dragged', () => {
renderRow ('tag-node:other')
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
})
})
+20 -59
ファイルの表示
@@ -4,8 +4,6 @@ import { motion } from 'framer-motion'
import { useRef } from 'react'
import TagLink from '@/components/TagLink'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { CSSProperties, FC, MutableRefObject } from 'react'
@@ -13,7 +11,6 @@ import type { CSSProperties, FC, MutableRefObject } from 'react'
import type { Tag } from '@/types'
type Props = {
activeDndId?: string
tag: Tag
nestLevel: number
pathKey: string
@@ -22,21 +19,7 @@ type Props = {
sp?: boolean }
const DraggableDroppableTagRow: FC<Props> = ({
activeDndId,
tag,
nestLevel,
pathKey,
parentTagId,
suppressClickRef,
sp,
}) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => {
const dndId = `tag-node:${ pathKey }`
const downPosRef = useRef<{ x: number; y: number } | null> (null)
@@ -62,23 +45,18 @@ const DraggableDroppableTagRow: FC<Props> = ({
const { attributes,
listeners,
setNodeRef: setDragRef,
transform } = useDraggable ({ id: dndId,
data: { kind: 'tag',
dndId,
tagId: tag.id,
parentTagId,
nestLevel } })
transform,
isDragging: dragging } = useDraggable ({ id: dndId,
data: { kind: 'tag',
tagId: tag.id,
parentTagId } })
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
id: dndId,
data: { kind: 'tag', tagId: tag.id } })
const activeDragging = activeDndId === dndId
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
visibility: activeDragging ? 'hidden' : 'visible' }
const innerClassName = cn (
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden',
sp && 'touch-pan-y')
visibility: dragging ? 'hidden' : 'visible' }
return (
<div
@@ -91,16 +69,12 @@ const DraggableDroppableTagRow: FC<Props> = ({
return
const dx = e.clientX - p.x
const dy = e.clientY - p.y
if (dx * dx + dy * dy >= 9)
armEatNextClick ()
}}
onPointerUpCapture={() => {
downPosRef.current = null
}}
onPointerCancelCapture={() => {
downPosRef.current = null
}}
onClickCapture={e => {
if (suppressClickRef.current)
{
@@ -108,35 +82,22 @@ const DraggableDroppableTagRow: FC<Props> = ({
e.stopPropagation ()
}
}}
ref={setDropRef}
ref={node => {
setDragRef (node)
setDropRef (node)
}}
style={style}
className={cn (
'min-w-0 max-w-full overflow-hidden rounded select-none',
sp && 'touch-pan-y',
over && 'ring-2 ring-offset-2')}
>
{activeDragging
? (
<div
ref={setDragRef}
style={style}
className={innerClassName}
{...attributes}
{...listeners}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</div>)
: (
<motion.div
ref={setDragRef}
style={style}
className={innerClassName}
{...attributes}
{...listeners}
transition={{ layout: layoutTransition }}
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>)}
{...attributes}
{...listeners}>
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>
</div>)
}
+1 -5
ファイルの表示
@@ -1,4 +1,3 @@
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useEffect, useRef, useState } from 'react'
@@ -21,8 +20,6 @@ const MIN_MARQUEE_OVERFLOW_PX = 1
const ResponsiveMarqueeText: FC<Props> = (
{ text, className, truncateOnMobile = false, title },
) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const outerRef = useRef<HTMLSpanElement | null> (null)
const staticRef = useRef<HTMLSpanElement | null> (null)
const animatedRef = useRef<HTMLSpanElement | null> (null)
@@ -101,7 +98,6 @@ const ResponsiveMarqueeText: FC<Props> = (
const animated = animatedRef.current
const canMarquee = (
active
&& animationMode !== 'off'
&& desktopMarqueeEnabled
&& overflowPx >= MIN_MARQUEE_OVERFLOW_PX
&& animated != null)
@@ -211,7 +207,7 @@ const ResponsiveMarqueeText: FC<Props> = (
resetAnimated ()
setMarqueeVisible (false)
}
}, [active, animationMode, desktopMarqueeEnabled, overflowPx, text])
}, [active, desktopMarqueeEnabled, overflowPx, text])
return (
<span
+74 -162
ファイルの表示
@@ -1,13 +1,11 @@
import { DndContext,
DragOverlay,
MeasuringStrategy,
MouseSensor,
TouchSensor,
pointerWithin,
useDroppable,
useSensor,
useSensors } from '@dnd-kit/core'
import { restrictToWindowEdges, snapCenterToCursor } from '@dnd-kit/modifiers'
import { restrictToWindowEdges } from '@dnd-kit/modifiers'
import { useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -22,28 +20,22 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, TagWithSections } from '@/types'
type TagByCategory = { [key in Category]: TagWithSections[] }
const tagCollisionDetection: CollisionDetection = args => {
return pointerWithin (args)
}
const alwaysMeasureDroppables = {
droppable: { strategy: MeasuringStrategy.Always } } as const
type FlatTagRow = {
tag: TagWithSections
parentTagId?: number }
const renderTagTree = (
activeDndId: string | undefined,
tag: TagWithSections,
nestLevel: number,
path: string,
@@ -55,7 +47,6 @@ const renderTagTree = (
const self = (
<li key={key} className="mb-1">
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
nestLevel={nestLevel}
pathKey={key}
@@ -69,17 +60,38 @@ const renderTagTree = (
...((tag.children
?.sort ((a, b) => a.name < b.name ? -1 : 1)
.flatMap (child =>
renderTagTree (
activeDndId,
child,
nestLevel + 1,
key,
suppressClickRef,
tag.id,
sp)))
renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id, sp)))
?? [])]
}
const flattenTagTree = (
tags: TagWithSections[],
): FlatTagRow[] => {
const seen = new Set<number> ()
const rows: FlatTagRow[] = []
const visit = (
tag: TagWithSections,
parentTagId?: number,
) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
rows.push ({ tag, parentTagId })
for (const child of tag.children ?? [])
visit (child, tag.id)
}
for (const tag of tags)
visit (tag)
return rows.sort ((rowA, rowB) => rowA.tag.name < rowB.tag.name ? -1 : 1)
}
const isDescendant = (
root: TagWithSections,
targetId: number,
@@ -146,37 +158,6 @@ const buildTagByCategory = (post: Post): TagByCategory => {
}
const buildFlatTagByCategory = (
byCategory: TagByCategory,
): TagByCategory => {
const tagsTmp = { } as TagByCategory
const seen = new Set<number> ()
for (const category of CATEGORIES)
tagsTmp[category] = []
const visit = (tag: TagWithSections) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
tagsTmp[tag.category].push ({ ...tag, children: [] })
for (const child of tag.children ?? [])
visit (child)
}
for (const category of CATEGORIES)
for (const tag of byCategory[category] ?? [])
visit (tag)
for (const category of CATEGORIES)
tagsTmp[category].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1)
return tagsTmp
}
const changeCategory = async (
tagId: number,
category: Category): Promise<void> => {
@@ -195,35 +176,12 @@ const DropSlot = ({ cat }: { cat: Category }) => {
</li>)
}
const EmptyCategoryDropSection = (
{ cat, children }: { cat: Category
children: ReactNode }) => {
const { setNodeRef, isOver: over } = useDroppable ({
id: `slot:${ cat }`,
data: { kind: 'slot', cat } })
return (
<div ref={setNodeRef} className="my-3">
{children}
<ul>
<li className="h-1">
{over && <div className="h-0.5 w-full rounded bg-sky-400"/>}
</li>
</ul>
</div>)
}
type Props = {
className?: string
post: Post
sp?: boolean }
type ActiveTagDrag = {
dndId: string
tagId: number
nestLevel: number }
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
sp = Boolean (sp)
@@ -231,11 +189,6 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped'
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const baseTags = useMemo<TagByCategory> (() => {
const tagsTmp = { } as TagByCategory
@@ -253,15 +206,10 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
return tagsTmp
}, [post])
const [activeTagDrag, setActiveTagDrag] = useState<ActiveTagDrag | null> (null)
const [activeTagId, setActiveTagId] = useState<number | null> (null)
const [dragging, setDragging] = useState (false)
const [saving, setSaving] = useState (false)
const [tags, setTags] = useState (baseTags)
const flatTagsByCategory = useMemo<TagByCategory> (
() => buildFlatTagByCategory (tags),
[tags],
)
const activeDndId = activeTagDrag?.dndId
const suppressClickRef = useRef (false)
@@ -370,19 +318,9 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
<TagSearch/>
<DndContext
sensors={sensors}
collisionDetection={tagCollisionDetection}
measuring={alwaysMeasureDroppables}
onDragStart={e => {
if (e.active.data.current?.kind === 'tag')
{
const tagId = e.active.data.current?.tagId
const nestLevel = e.active.data.current?.nestLevel
const dndId = e.active.data.current?.dndId
setActiveTagDrag (
tagId == null || nestLevel == null || dndId == null
? null
: { dndId, tagId, nestLevel })
}
setActiveTagId (e.active.data.current?.tagId ?? null)
setDragging (true)
suppressClickRef.current = true
document.body.style.userSelect = 'none'
@@ -393,80 +331,57 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
suppressClickRef.current = false}, { capture: true, once: true })
}}
onDragCancel={() => {
setActiveTagDrag (null)
setActiveTagId (null)
setDragging (false)
document.body.style.userSelect = ''
suppressClickRef.current = false
}}
onDragEnd={async e => {
setActiveTagDrag (null)
setActiveTagId (null)
setDragging (false)
await onDragEnd (e)
document.body.style.userSelect = ''
}}
modifiers={[restrictToWindowEdges]}>
{CATEGORIES.map ((cat: Category) => {
const categoryTags =
tagRelationDisplay === 'grouped'
? (tags[cat] ?? [])
: (flatTagsByCategory[cat] ?? [])
if (!(categoryTags.length > 0 || dragging))
return null
const sectionTitle = (
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
<div className="my-3" key={cat}>
<SubsectionTitle>
<motion.div
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: layoutTransition }}>
layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
{CATEGORY_NAMES[cat]}
</motion.div>
</SubsectionTitle>)
</SubsectionTitle>
if (!(categoryTags.length > 0))
return (
<EmptyCategoryDropSection cat={cat} key={cat}>
{sectionTitle}
</EmptyCategoryDropSection>)
return (
<div className="my-3" key={cat}>
{sectionTitle}
<ul>
{(tagRelationDisplay === 'grouped'
? (tags[cat] ?? []).flatMap (tag =>
renderTagTree (
activeDndId,
tag,
0,
`cat-${ cat }`,
suppressClickRef,
undefined,
sp,
),
)
: (flatTagsByCategory[cat] ?? []).map (tag => (
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
nestLevel={0}
pathKey={`flat-${ cat }-${ tag.id }`}
suppressClickRef={suppressClickRef}
sp={sp}/>
</li>)))}
<DropSlot cat={cat}/>
</ul>
</div>)
})}
<ul>
{(tagRelationDisplay === 'grouped'
? (tags[cat] ?? []).flatMap (tag =>
renderTagTree (
tag,
0,
`cat-${ cat }`,
suppressClickRef,
undefined,
sp,
),
)
: flattenTagTree (tags[cat] ?? []).map (row => (
<li key={`flat-${ cat }-${ row.tag.id }`} className="mb-1">
<DraggableDroppableTagRow
tag={row.tag}
nestLevel={0}
pathKey={`flat-${ cat }-${ row.tag.id }`}
parentTagId={row.parentTagId}
suppressClickRef={suppressClickRef}
sp={sp}/>
</li>)))}
<DropSlot cat={cat}/>
</ul>
</div>))}
{post && (
<motion.div
layoutId={animationMode === 'off'
? undefined
: `post-info-${ sp }`}
transition={{ layout: layoutTransition }}>
layoutId={`post-info-${ sp }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<SectionTitle></SectionTitle>
<ul>
<li>Id.: {post.id}</li>
@@ -503,14 +418,11 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
</ul>
</motion.div>)}
<DragOverlay
adjustScale={false}
modifiers={[snapCenterToCursor]}
dropAnimation={animationMode === 'off' ? null : undefined}>
<DragOverlay adjustScale={false}>
<div className="pointer-events-none">
{activeTagDrag != null && (() => {
const tag = findTag (tags, activeTagDrag.tagId)
return tag && <TagLink tag={tag} nestLevel={activeTagDrag.nestLevel}/>
{activeTagId != null && (() => {
const tag = findTag (tags, activeTagId)
return tag && <TagLink tag={tag}/>
}) ()}
</div>
</DragOverlay>
+18 -40
ファイルの表示
@@ -8,8 +8,6 @@ import SectionTitle from '@/components/common/SectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { CATEGORIES } from '@/consts'
import { apiGet } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import type { FC, MouseEvent } from 'react'
@@ -23,13 +21,6 @@ type Props = { posts: Post[]
const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const navigate = useNavigate ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const [tagsVsbl, setTagsVsbl] = useState (false)
const [tags, setTags] = useState<TagByCategory> ({ })
@@ -74,18 +65,12 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1 min-w-0 max-w-full overflow-hidden">
{animationsOff
? (
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
<TagLink tag={tag} onClick={onClick}/>
</div>)
: (
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: layoutTransition }}
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>)}
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>
</li>))) : [])}
</ul>
<SectionTitle></SectionTitle>
@@ -118,25 +103,18 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
{posts.length > 0 && TagBlock}
</div>
{animationsOff
? (
tagsVsbl && (
<div className="md:hidden overflow-hidden">
{posts.length > 0 && TagBlock}
</div>))
: (
<AnimatePresence initial={false}>
{tagsVsbl && (
<motion.div
key="sptags"
className="md:hidden overflow-hidden"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={layoutTransition}>
{posts.length > 0 && TagBlock}
</motion.div>)}
</AnimatePresence>)}
<AnimatePresence initial={false}>
{tagsVsbl && (
<motion.div
key="sptags"
className="md:hidden overflow-hidden"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: .2, ease: 'easeOut' }}>
{posts.length > 0 && TagBlock}
</motion.div>)}
</AnimatePresence>
<a href="#"
className="md:hidden block my-2 text-center text-sm
+6 -10
ファイルの表示
@@ -161,17 +161,13 @@ const TopNav: FC<Props> = ({ user }) => {
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
const topNavTransition =
animationsOff
? { duration: 0 }
: reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const opacityTransition =
animationsOff
? { duration: 0 }
: reducedAnimations
? { duration: .08 }
: { duration: .12 }
reducedAnimations
? { duration: .08 }
: { duration: .12 }
const highlightTransitionClass =
animationsOff
? undefined
-12
ファイルの表示
@@ -20,16 +20,4 @@ describe ('TabGroup', () => {
expect (screen.getByText ('Alpha')).toBeInTheDocument ()
expect (screen.queryByText ('Beta')).not.toBeInTheDocument ()
})
it ('renders tabs as buttons and shows the right addon', () => {
render (
<TabGroup rightAddon={<span> 20</span>}>
<Tab name="広場">Plaza</Tab>
</TabGroup>,
)
expect (screen.getByRole ('button', { name: '広場' })).toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '広場' })).not.toBeInTheDocument ()
expect (screen.getByText ('件数 20')).toBeInTheDocument ()
})
})
+8 -19
ファイルの表示
@@ -1,7 +1,5 @@
import { motion } from 'framer-motion'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
@@ -11,21 +9,12 @@ type Props = {
className?: string }
const MainArea: FC<Props> = ({ children, className }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const MainArea: FC<Props> = ({ children, className }) => (
<motion.main
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout="position">
{children}
</motion.main>)
return (
<motion.main
transition={{ layout: layoutTransition }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout={animationMode === 'off' ? false : 'position'}>
{children}
</motion.main>)
}
export default MainArea
export default MainArea
+2 -10
ファイルの表示
@@ -1,8 +1,6 @@
import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
@@ -70,12 +68,6 @@ const SidebarComponent: FC<Props> = ({
sidebarKey,
side = 'left',
}) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth)
const getContainerWidth = useCallback (() => {
@@ -313,8 +305,8 @@ const SidebarComponent: FC<Props> = ({
return (
<motion.div
ref={rootRef}
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: layoutTransition }}
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
style={style}
className={cn (
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
+36 -10
ファイルの表示
@@ -11,7 +11,8 @@ import type { ClientAnimationMode,
ClientBehaviorSettings,
ClientEmbedAutoLoadMode,
ClientLinkPreloadMode,
ClientTagRelationDisplayMode } from '@/lib/settings'
ClientTagRelationDisplayMode,
ClientThumbnailMode } from '@/lib/settings'
type Props = {
sectionClassName: string
@@ -30,9 +31,9 @@ type SegmentedControlProps<T extends string> = {
onChange: (value: T) => void }
type SettingBlockProps = {
title: string
description?: string
children: ReactNode }
title: string
description: string
children: ReactNode }
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
{ value: 'off', label: 'なし' },
@@ -46,12 +47,18 @@ const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [
{ value: 'off', label: 'しない' },
{ value: 'intent', label: 'する' }]
{ value: 'intent', label: 'マウスを置いたら' }]
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
{ value: 'flat', label: 'まとめない' },
{ value: 'grouped', label: 'まとめる' }]
const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [
{ value: 'off', label: '非表示' },
{ value: 'light', label: '軽量' },
{ value: 'normal', label: '通常' }]
const SegmentedControl = <T extends string,> (
{ value, options, onChange }: SegmentedControlProps<T>,
) => {
@@ -83,7 +90,7 @@ const SegmentedControl = <T extends string,> (
const SettingBlock: FC<SettingBlockProps> = (
{ title, description, children },
) => (
<div className="space-y-3 rounded-xl px-4">
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
@@ -142,6 +149,10 @@ const BehaviourSettingsSection: FC<Props> = (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div>
<div className="flex flex-wrap gap-2">
@@ -162,7 +173,9 @@ const BehaviourSettingsSection: FC<Props> = (
</div>
<div className="space-y-4">
<SettingBlock title="アニメーション">
<SettingBlock
title="アニメーション"
description="画面遷移やレイアウト移動の動きを調整します。">
<SegmentedControl
value={draftSettings.animation ?? 'normal'}
options={animationOptions}
@@ -171,26 +184,39 @@ const BehaviourSettingsSection: FC<Props> = (
<SettingBlock
title="リンク先の先読み"
description="マウスを置いた時点でリンク先のデータを取得します。">
description="リンクにマウスを置いた時などに、移動先の情報を先に読みます。通信量や意図しない読込みが気になる場合は「しない」にできます。">
<SegmentedControl
value={draftSettings.linkPreload ?? 'intent'}
options={linkPreloadOptions}
onChange={value => updateDraft ('linkPreload', value)}/>
</SettingBlock>
<SettingBlock title="タグの親子関係表示">
<SettingBlock
title="タグの親子関係表示"
description="親子関係があるタグを、まとまりとして表示します。見た目を単純にしたい場合は「まとめない」にできます。">
<SegmentedControl
value={draftSettings.tagRelationDisplay ?? 'grouped'}
options={tagRelationDisplayOptions}
onChange={value => updateDraft ('tagRelationDisplay', value)}/>
</SettingBlock>
<SettingBlock title="埋め込み自動読込">
<SettingBlock
title="埋め込み自動読込"
description="外部埋め込みを自動で読み込むかを切り替えます。">
<SegmentedControl
value={draftSettings.embedAutoLoad ?? 'auto'}
options={embedAutoLoadOptions}
onChange={value => updateDraft ('embedAutoLoad', value)}/>
</SettingBlock>
<SettingBlock
title="サムネイル表示"
description="投稿一覧や詳細の画像表示を軽量化します。">
<SegmentedControl
value={draftSettings.thumbnailMode ?? 'normal'}
options={thumbnailModeOptions}
onChange={value => updateDraft ('thumbnailMode', value)}/>
</SettingBlock>
</div>
</section>)
}
+6 -2
ファイルの表示
@@ -147,6 +147,10 @@ const KeyboardSettingsSection: FC<Props> = (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div>
<div className="flex flex-wrap gap-2">
@@ -202,8 +206,8 @@ const KeyboardSettingsSection: FC<Props> = (
<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>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
</tr>
</thead>
+2 -9
ファイルの表示
@@ -137,11 +137,9 @@
:root[data-animation='off'] *::before,
:root[data-animation='off'] *::after
{
animation-duration: 0ms !important;
animation-delay: 0ms !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0ms !important;
transition-delay: 0ms !important;
transition-duration: 0.001ms !important;
}
a
@@ -239,11 +237,6 @@ body
background: var(--top-nav-submenu-bg);
}
.top-nav-mobile-menu .top-nav-submenu
{
background: var(--top-nav-mobile-active-bg);
}
.top-nav-mobile-menu
{
background: var(--top-nav-mobile-menu-bg);
-22
ファイルの表示
@@ -1,22 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const indexCss = readFileSync (
resolve (process.cwd (), 'src/index.css'),
'utf8')
const compactIndexCss = indexCss.replace (/\s+/g, ' ')
const mobileActiveRule = '.top-nav-mobile-active {'
+ ' background: var(--top-nav-mobile-active-bg); }'
const mobileSubmenuRule = '.top-nav-mobile-menu .top-nav-submenu {'
+ ' background: var(--top-nav-mobile-active-bg); }'
describe ('TopNav mobile menu colours', () => {
it ('uses one background variable for active rows and expanded submenus', () => {
expect (compactIndexCss).toContain (mobileActiveRule)
expect (compactIndexCss).toContain (mobileSubmenuRule)
})
})
-34
ファイルの表示
@@ -1,34 +0,0 @@
import type { Transition } from 'framer-motion'
import type { ClientAnimationMode } from '@/lib/settings'
type ClientAnimationTransitionOptions = {
normal: Transition
reduced?: Transition
off?: Transition }
export const clientAnimationsOff = (
animationMode: ClientAnimationMode,
): boolean =>
animationMode === 'off'
export const clientAnimationTransition = (
animationMode: ClientAnimationMode,
{ normal, reduced, off }: ClientAnimationTransitionOptions,
): Transition => {
if (animationMode === 'off')
return off ?? { duration: 0 }
if (animationMode === 'reduced')
return reduced ?? { duration: .08, ease: 'linear' as const }
return normal
}
export const clientScrollBehaviour = (
animationMode: ClientAnimationMode,
): ScrollBehavior =>
animationMode === 'off' ? 'auto' : 'smooth'
-65
ファイルの表示
@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_KEY_BINDINGS,
formatKeyBinding,
getConflictingShortcutActionIds,
isEditableEventTarget,
keyBindingFromKeyboardEvent,
resolveShortcutScope,
SHORTCUT_DEFINITIONS_BY_ID } from '@/lib/keyboardShortcuts'
describe ('keyboardShortcuts', () => {
it ('defines the default key bindings used by the settings screen', () => {
expect (SHORTCUT_DEFINITIONS_BY_ID['global.openShortcutHelp']).toMatchObject ({
label: 'ショートカット一覧を開く',
scope: 'global',
defaultBinding: { key: '?', shift: true } })
expect (DEFAULT_KEY_BINDINGS['global.focusSearch']).toEqual ({ key: '/' })
expect (DEFAULT_KEY_BINDINGS['navigation.posts']).toEqual ({ key: 'p' })
expect (DEFAULT_KEY_BINDINGS['pagination.next']).toEqual ({ key: 'ArrowRight' })
expect (DEFAULT_KEY_BINDINGS['settings.theme']).toEqual ({ key: '3' })
})
it ('formats key bindings for display', () => {
expect (formatKeyBinding ({ key: '?', shift: true })).toBe ('Shift + ?')
expect (formatKeyBinding ({ key: '/' })).toBe ('/')
expect (formatKeyBinding ({ key: 'p' })).toBe ('P')
expect (formatKeyBinding ({ key: 'ArrowRight' })).toBe ('ArrowRight')
expect (formatKeyBinding (null)).toBe ('未割当')
})
it ('normalises keyboard events without assigning space by default', () => {
const event = { key: 'P',
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false } as KeyboardEvent
expect (keyBindingFromKeyboardEvent (event)).toEqual ({ key: 'p' })
expect (DEFAULT_KEY_BINDINGS['pagination.next']).not.toEqual ({ key: ' ' })
})
it ('detects conflicting action bindings', () => {
const conflicts = getConflictingShortcutActionIds ({
...DEFAULT_KEY_BINDINGS,
'navigation.posts': { key: 'p' },
'navigation.tags': { key: 'p' } })
expect (conflicts.has ('navigation.posts')).toBe (true)
expect (conflicts.has ('navigation.tags')).toBe (true)
expect (conflicts.has ('navigation.wiki')).toBe (false)
})
it ('resolves route scopes and editable event targets', () => {
const input = document.createElement ('input')
const div = document.createElement ('div')
div.setAttribute ('role', 'textbox')
expect (resolveShortcutScope ('/posts/search')).toBe ('postSearch')
expect (resolveShortcutScope ('/users/settings')).toBe ('settings')
expect (resolveShortcutScope ('/materials/1')).toBe ('materials')
expect (isEditableEventTarget (input)).toBe (true)
expect (isEditableEventTarget (div)).toBe (true)
expect (isEditableEventTarget (document.createElement ('button'))).toBe (false)
})
})
-112
ファイルの表示
@@ -1,112 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { applyClientAnimationMode,
applyThemeTokens,
buildThemeTokens,
CLIENT_SETTINGS_STORAGE_KEY,
DARK_THEME_TOKENS,
DEFAULT_POST_LIST_ORDER,
getClientListLimit,
getClientListOrder,
getClientThemeMode,
LIGHT_THEME_TOKENS,
loadClientSettings,
setClientListSettings,
setClientThemeAppearance } from '@/lib/settings'
import type { FetchPostsOrder } from '@/types'
const installMatchMedia = (matches: boolean) => {
Object.defineProperty (window, 'matchMedia', {
configurable: true,
value: vi.fn ().mockImplementation (() => ({
matches,
addEventListener: vi.fn (),
removeEventListener: vi.fn () })) })
}
describe ('settings', () => {
beforeEach (() => {
localStorage.clear ()
document.documentElement.removeAttribute ('data-animation')
document.documentElement.removeAttribute ('style')
document.documentElement.className = ''
installMatchMedia (false)
})
it ('stores list settings under the shared client settings key', () => {
setClientListSettings ('postSearch', {
limit: 50,
order: 'title:asc' })
expect (getClientListLimit ('postSearch')).toBe (50)
expect (getClientListOrder<FetchPostsOrder> ('postSearch')).toBe ('title:asc')
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
.toMatchObject ({ lists: { postSearch: { limit: 50, order: 'title:asc' } } })
})
it ('ignores invalid list limits and keeps stored order strings', () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { postList: { limit: 30, order: DEFAULT_POST_LIST_ORDER } } }))
expect (getClientListLimit ('postList')).toBeNull ()
expect (getClientListOrder<FetchPostsOrder> ('postList')).toBe (
DEFAULT_POST_LIST_ORDER)
})
it ('stores theme mode and light/dark slot selections in client settings', () => {
setClientThemeAppearance ({
themeMode: 'dark',
activeLightThemeSlotNo: 2,
activeDarkThemeSlotNo: 3 })
expect (getClientThemeMode ()).toBe ('dark')
expect (loadClientSettings ().appearance).toMatchObject ({
themeMode: 'dark',
activeLightThemeSlotNo: 2,
activeDarkThemeSlotNo: 3 })
})
it ('applies animation mode to the document dataset', () => {
applyClientAnimationMode ('off')
expect (document.documentElement.dataset.animation).toBe ('off')
applyClientAnimationMode ('reduced')
expect (document.documentElement.dataset.animation).toBe ('reduced')
applyClientAnimationMode ('normal')
expect (document.documentElement.dataset.animation).toBeUndefined ()
})
it ('normalises TopNav menu link tokens to complete CSS colours', () => {
const tokens = buildThemeTokens ('light', {
link: '221.2 83.2% 53.3%',
topNavMenuLink: '221.2 83.2% 53.3%' })
expect (tokens.link).toBe ('221.2 83.2% 53.3%')
expect (tokens.topNavMenuLink).toBe ('hsl(221.2 83.2% 53.3%)')
})
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
const tokens = buildThemeTokens ('light', { })
expect (tokens.topNavMobileActiveBackground).toBe (
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
expect (tokens.topNavMobileActiveBackground).not.toBe (
tokens.topNavRootBackgroundMobile)
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
DARK_THEME_TOKENS.topNavActiveBackground)
})
it ('sets shadcn tokens and TopNav tokens with their different value formats', () => {
const tokens = buildThemeTokens ('dark', { link: '213.1 93.9% 67.8%' })
applyThemeTokens (tokens)
expect (document.documentElement.style.getPropertyValue ('--theme-link')).toBe (
'213.1 93.9% 67.8%')
expect (document.documentElement.style.getPropertyValue ('--top-nav-menu-link')).toBe (
'hsl(213.1 93.9% 67.8%)')
})
})
+2 -19
ファイルの表示
@@ -291,27 +291,10 @@ export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue =>
export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => {
const { registerHandlers } = useKeyboardShortcutSettings ()
const handlersRef = useRef (handlers)
const actionIdsKey = Object.keys (handlers).sort ().join ('|')
useEffect (() => {
handlersRef.current = handlers
}, [handlers])
const stableHandlers = useMemo<ShortcutHandlers> (() => {
return actionIdsKey.split ('|').filter (actionId => actionId !== '')
.reduce<ShortcutHandlers> ((nextHandlers, actionId) => {
const key = actionId as ShortcutActionId
nextHandlers[key] = () => {
handlersRef.current[key]?.()
}
return nextHandlers
}, { })
}, [actionIdsKey])
useEffect (
() => registerHandlers (stableHandlers),
[registerHandlers, stableHandlers],
() => registerHandlers (handlers),
[handlers, registerHandlers],
)
}
+1 -7
ファイルの表示
@@ -28,7 +28,6 @@ import {
getClientGekanatorBackgroundMotion,
setClientGekanatorBackgroundMotion,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
@@ -3678,8 +3677,6 @@ const expectedAnswerFor = (
const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const storedGame = useMemo (loadStoredGame, [])
const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase)
const queryClient = useQueryClient ()
@@ -4072,12 +4069,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
return reviewGuessedPostId === reviewCorrectPostId
}) ()
const effectiveBackgroundMotionMode = (() => {
if (animationMode === 'off')
return 'off'
if (backgroundMotionMode === 'off')
return 'off'
if (animationMode === 'reduced' || prefersReducedMotion)
if (prefersReducedMotion)
return 'calm'
return backgroundMotionMode
+2 -7
ファイルの表示
@@ -9,18 +9,14 @@ import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchMaterialChanges } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
const MaterialHistoryPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation ()
const navigate = useNavigate ()
const query = new URLSearchParams (location.search)
@@ -59,9 +55,8 @@ const MaterialHistoryPage: FC = () => {
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search])
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
const handleSearch = (event: FormEvent) => {
event.preventDefault ()
+4 -14
ファイルの表示
@@ -12,12 +12,9 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPostChanges, updatePost } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, MouseEvent } from 'react'
@@ -40,12 +37,6 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
const PostHistoryPage: FC = () => {
const dialogue = useDialogue ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const location = useLocation ()
const query = new URLSearchParams (location.search)
@@ -122,9 +113,8 @@ const PostHistoryPage: FC = () => {
}
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search])
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
const layoutIds: string[] = []
@@ -208,8 +198,8 @@ const PostHistoryPage: FC = () => {
rowSpan={rowsCnt}>
<PrefetchLink to={`/posts/${ change.postId }`}>
<motion.div
layoutId={animationMode === 'off' ? undefined : layoutId}
transition={{ layout: layoutTransition }}>
layoutId={layoutId}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<img src={change.thumbnail.current
|| change.thumbnailBase.current
|| undefined}
+1 -40
ファイルの表示
@@ -1,10 +1,9 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import PostListPage from '@/pages/posts/PostListPage'
import { buildPost, buildTag, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (),
@@ -19,11 +18,6 @@ vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/wiki', () => wikiApi)
describe ('PostListPage', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('loads posts from the current query and renders the plaza tab', async () => {
const tag = buildTag ({ name: '虹夏' })
postsApi.fetchPosts.mockResolvedValueOnce ({
@@ -42,7 +36,6 @@ describe ('PostListPage', () => {
match: 'all',
page: 2,
limit: 20,
order: 'original_created_at:desc',
}),
)
})
@@ -78,36 +71,4 @@ describe ('PostListPage', () => {
'/wiki/%E8%99%B9%E5%A4%8F',
)
})
it ('uses stored list limit but keeps the fixed plaza order', async () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { postList: { limit: 50, order: 'title:asc' } } }))
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
renderWithProviders (<PostListPage/>, { route: '/posts' })
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({
limit: 50,
order: 'original_created_at:desc',
}),
)
})
})
it ('updates the limit from the tab right addon and resets to page 1', async () => {
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
renderWithProviders (<PostListPage/>, { route: '/posts?page=3' })
fireEvent.change (await screen.findByLabelText ('件数'), { target: { value: '100' } })
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
)
})
})
})
+9 -1
ファイルの表示
@@ -30,12 +30,16 @@ describe ('PostNewPage', () => {
expect (screen.getByText ('403')).toBeInTheDocument ()
})
it ('submits a new post with manual title and thumbnail fetch UI', async () => {
it ('submits a new post with manual title and thumbnail settings', async () => {
api.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([])
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
fireEvent.click (checkboxes[0])
fireEvent.click (checkboxes[1])
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
@@ -93,6 +97,10 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
fireEvent.click (checkboxes[0])
fireEvent.click (checkboxes[1])
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
-40
ファイルの表示
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostSearchPage from '@/pages/posts/PostSearchPage'
import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (),
@@ -19,7 +18,6 @@ vi.mock ('@/lib/api', () => api)
describe ('PostSearchPage', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
api.apiGet.mockResolvedValue ([])
})
@@ -42,7 +40,6 @@ describe ('PostSearchPage', () => {
tags: 'x',
match: 'any',
page: 2,
limit: 20,
}),
)
})
@@ -84,41 +81,4 @@ describe ('PostSearchPage', () => {
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument ()
})
it ('uses URL limit before localStorage and stores the active list settings', async () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { postSearch: { limit: 100, order: 'title:asc' } } }))
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
renderWithProviders (
<PostSearchPage/>,
{ route: '/posts/search?limit=50&order=created_at%3Adesc' },
)
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({ limit: 50, order: 'created_at:desc' }),
)
})
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
.toMatchObject ({ lists: { postSearch: { limit: 50, order: 'created_at:desc' } } })
})
it ('moves the compact limit control outside the search form and resets page on change', async () => {
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
renderWithProviders (<PostSearchPage/>, { route: '/posts/search?page=4' })
const limitSelect = await screen.findByLabelText ('件数')
expect (limitSelect.closest ('form')).toBeNull ()
fireEvent.change (limitSelect, { target: { value: '100' } })
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
)
})
})
})
+8 -19
ファイルの表示
@@ -14,8 +14,6 @@ import Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import {
@@ -26,7 +24,6 @@ import {
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
@@ -70,13 +67,6 @@ const parseOrder = (value: string | null): FetchPostsOrder | null => {
const PostSearchPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const thumbnailTransition = clientAnimationTransition (
animationMode,
{ normal: { type: 'spring', stiffness: 500, damping: 40, mass: .5 },
reduced: { duration: .08, ease: 'linear' as const } },
)
const location = useLocation ()
const navigate = useNavigate ()
@@ -151,11 +141,9 @@ const PostSearchPage: FC = () => {
setUpdatedFrom (qUpdatedFrom)
setUpdatedTo (qUpdatedTo)
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search, qCreatedFrom, qCreatedTo, qMatch,
qOriginalCreatedFrom, qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom,
qUpdatedTo, qURL])
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedFrom,
qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, qUpdatedTo, qURL])
const search = async () => {
const qs = new URLSearchParams ()
@@ -412,10 +400,11 @@ const PostSearchPage: FC = () => {
<td className="p-2">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
<motion.div
layoutId={animationMode === 'off'
? undefined
: `page-${ row.id }`}
transition={thumbnailTransition}>
layoutId={`page-${ row.id }`}
transition={{ type: 'spring',
stiffness: 500,
damping: 40,
mass: .5 }}>
<img src={row.thumbnail || row.thumbnailBase || undefined}
alt={row.title || row.url}
title={row.title || row.url || undefined}
-2
ファイルの表示
@@ -6,7 +6,6 @@ import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({
@@ -28,7 +27,6 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue,
}))
+2 -7
ファイルの表示
@@ -17,10 +17,8 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api'
import { extractValidationError } from '@/lib/apiErrors'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchNicoTags } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, inputClass } from '@/lib/utils'
import { canEditContent } from '@/lib/users'
@@ -40,8 +38,6 @@ const setIf = (qs: URLSearchParams, key: string, value: string) => {
const NicoTagListPage: FC<Props> = ({ user }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const dialogue = useDialogue ()
const location = useLocation ()
const navigate = useNavigate ()
@@ -156,9 +152,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setLinkStatus (qLinkStatus)
setEditingId (null)
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search, qLinkedTag, qLinkStatus, qName])
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search, qLinkedTag, qLinkStatus, qName])
useEffect (() => {
if (!(data))
+2 -7
ファイルの表示
@@ -11,10 +11,8 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { CATEGORY_NAMES } from '@/consts'
import { apiPut } from '@/lib/api'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTagChanges } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString } from '@/lib/utils'
import type { FC } from 'react'
@@ -45,8 +43,6 @@ const renderStateDiff = (diff: { current: string | null; prev: string | null })
const TagHistoryPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation ()
const query = new URLSearchParams (location.search)
const id = query.get ('id')
@@ -62,9 +58,8 @@ const TagHistoryPage: FC = () => {
const qc = useQueryClient ()
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search])
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
return (
<MainArea>
+3 -48
ファイルの表示
@@ -1,10 +1,9 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import TagListPage from '@/pages/tags/TagListPage'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const tagsApi = vi.hoisted (() => ({
fetchTags: vi.fn (),
@@ -13,11 +12,6 @@ const tagsApi = vi.hoisted (() => ({
vi.mock ('@/lib/tags', () => tagsApi)
describe ('TagListPage', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('loads tags from URL filters and renders the results table', async () => {
tagsApi.fetchTags.mockResolvedValueOnce ({
tags: [buildTag ({
@@ -44,8 +38,6 @@ describe ('TagListPage', () => {
name: '虹',
category: 'character',
page: 3,
limit: 20,
order: 'post_count:desc',
postCountGTE: 5,
deprecated: true,
}),
@@ -56,7 +48,7 @@ describe ('TagListPage', () => {
'/tags/7',
)
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1')
expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1')
expect (screen.getAllByText ('廃止')).toHaveLength (2)
})
@@ -66,7 +58,7 @@ describe ('TagListPage', () => {
renderWithProviders (<TagListPage/>, { route: '/tags' })
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
fireEvent.change (screen.getAllByRole ('combobox')[1], {
fireEvent.change (screen.getAllByRole ('combobox')[0], {
target: { value: 'character' },
})
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
@@ -90,41 +82,4 @@ describe ('TagListPage', () => {
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument ()
})
it ('uses URL limit before localStorage and preserves the query order', async () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { tagList: { limit: 100, order: 'name:asc' } } }))
tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [], count: 0 })
renderWithProviders (
<TagListPage/>,
{ route: '/tags?limit=50&order=updated_at%3Adesc' },
)
await waitFor (() => {
expect (tagsApi.fetchTags).toHaveBeenCalledWith (
expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }),
)
})
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
.toMatchObject ({ lists: { tagList: { limit: 50, order: 'updated_at:desc' } } })
})
it ('keeps the compact limit control outside the search form and resets page on change', async () => {
tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 })
renderWithProviders (<TagListPage/>, { route: '/tags?page=4' })
const limitSelect = await screen.findByLabelText ('件数')
expect (limitSelect.closest ('form')).toBeNull ()
fireEvent.change (limitSelect, { target: { value: '100' } })
await waitFor (() => {
expect (tagsApi.fetchTags).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
)
})
})
})
+3 -9
ファイルの表示
@@ -13,7 +13,6 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { tagsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
@@ -23,7 +22,6 @@ import {
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchTags } from '@/lib/tags'
import { dateString, inputClass } from '@/lib/utils'
@@ -73,8 +71,6 @@ const parseOrder = (value: string | null): FetchTagsOrder | null => {
const TagListPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation ()
const navigate = useNavigate ()
@@ -149,11 +145,9 @@ const TagListPage: FC = () => {
setUpdatedTo (qUpdatedTo)
setDeprecated (qDeprecated)
document.querySelector ('table')?.scrollIntoView ({
behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search, qCategory, qCreatedFrom, qCreatedTo,
qName, qPostCountGTE, qPostCountLTE, qUpdatedFrom, qUpdatedTo,
qDeprecated])
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE,
qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated])
const handleSearch = (e: FormEvent) => {
e.preventDefault ()
-1
ファイルの表示
@@ -41,7 +41,6 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue,
}))
vi.mock ('@/components/PostEmbed', () => ({
+6 -15
ファイルの表示
@@ -15,8 +15,6 @@ import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPost } from '@/lib/posts'
import {
getClientTheatreLayoutMode,
@@ -24,7 +22,6 @@ import {
setClientTheatreLayoutMode,
setClientTheatreTagFlow,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -60,6 +57,8 @@ const INITIAL_THEATRE_INFO: TheatreInfo =
const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
{ tagPenalties: [], lightestPosts: [], heaviestPosts: [] }
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const MIN_MAIN_WIDTH = 360
const SIDEBAR_GAP_WIDTH = 16
@@ -215,12 +214,6 @@ type Props = { user: User | null }
const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const { id } = useParams ()
const dialogue = useDialogue ()
@@ -627,9 +620,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
clearValidationErrors ()
await apiPost (`/theatres/${ id }/comments`, { content })
setContent ('')
commentsRef.current?.scrollTo ({
top: 0,
behavior: clientScrollBehaviour (animationMode) })
commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' })
}
catch (error)
{
@@ -846,8 +837,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return (
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: layoutTransition }}
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950
md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
<Helmet>
@@ -871,7 +862,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</SidebarComponent>)}
<motion.main
layout={animationMode === 'off' ? false : 'position'}
layout="position"
className="order-1 min-w-0 flex-1 space-y-4 md:order-none
md:min-w-[360px] md:overflow-y-auto">
<div className="space-y-4">
+8 -22
ファイルの表示
@@ -6,7 +6,6 @@ import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
@@ -28,23 +27,12 @@ describe ('SettingPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
api.apiGet.mockImplementation ((path: string) => {
if (path === '/users/settings')
return Promise.resolve ({
theme: 'system',
auto_fetch_title: 'manual',
auto_fetch_thumbnail: 'manual',
wiki_editor_mode: 'split' })
if (path === '/users/theme_slots')
return Promise.resolve ([])
return Promise.resolve ({})
})
})
it ('shows loading when user is absent', () => {
renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>)
expect (screen.getAllByText ('Loading...').length).toBeGreaterThan (0)
expect (screen.getByText ('Loading...')).toBeInTheDocument ()
})
it ('updates the current user name', async () => {
@@ -54,9 +42,8 @@ describe ('SettingPage', () => {
renderWithProviders (<SettingPage user={user} setUser={setUser}/>)
const nameInput = (await screen.findAllByRole ('textbox'))[0]
fireEvent.change (nameInput, { target: { value: 'new' } })
fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0])
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'new' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
@@ -68,7 +55,7 @@ describe ('SettingPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('name')).toBe ('new')
expect (setUser).toHaveBeenCalled ()
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '表示名を更新しました.' })
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' })
})
it ('shows validation errors returned for the name field', async () => {
@@ -88,11 +75,10 @@ describe ('SettingPage', () => {
renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>)
const nameInput = (await screen.findAllByRole ('textbox'))[0]
fireEvent.change (nameInput, { target: { value: '' } })
fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0])
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
expect ((await screen.findAllByText ('名前は必須です.')).length).toBeGreaterThan (0)
expect (nameInput).toHaveAttribute ('aria-invalid', 'true')
expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument ()
expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true')
})
})
+68 -18
ファイルの表示
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom'
@@ -152,7 +152,7 @@ const themeSlotNoSelections: ThemeSegmentedOption<UserThemeSlotNo>[] = [
{ value: 3, label: '3' } ]
const sectionClassName =
'space-y-4 rounded-xl p-4'
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
const themeSelections: ThemeSelectionItem[] = [
{ id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 },
@@ -168,7 +168,24 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
fields: [
{ key: 'background', label: '背景' },
{ key: 'foreground', label: '文字' },
{ key: 'link', label: 'リンク' }] }]
{ key: 'primary', label: '主色' },
{ key: 'accent', label: 'アクセント' },
{ key: 'link', label: 'リンク' },
] },
{ title: '面',
fields: [
{ key: 'card', label: 'カード' },
{ key: 'muted', label: '薄い背景' },
{ key: 'border', label: '境界線' },
] },
{ title: '上部ナビ',
fields: [
{ key: 'topNavRootBackgroundMobile', label: 'スマホ背景' },
{ key: 'topNavRootBackgroundDesktop', label: 'PC 背景' },
{ key: 'topNavActiveBackground', label: '選択中背景' },
{ key: 'topNavBrandLink', label: 'ロゴ色' },
] },
]
const ThemeSegmentedControl = <T extends string | number,> (
@@ -481,6 +498,8 @@ const ThemeSection: FC<ThemeSectionProps> = (
draftActiveDarkThemeSlotNo,
currentDisplayedThemeSlot,
selectedTheme,
displayedThemeLabel,
systemThemeLabel,
hasUnsavedChanges,
settingsBaseErrors,
settingsFieldErrors,
@@ -498,6 +517,16 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
: {displayedThemeLabel}
</p>
{draftThemeMode === 'system' && (
<p className="text-sm text-muted-foreground">
: {systemThemeLabel}
</p>)}
<p className="text-sm text-muted-foreground">
: {themeLabelBySelection (currentDisplayedThemeSlot)}
</p>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
@@ -505,13 +534,6 @@ const ThemeSection: FC<ThemeSectionProps> = (
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={onResetThemeSlot}>
</Button>
<Button
type="button"
variant="destructive"
@@ -531,7 +553,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
<FieldError messages={settingsBaseErrors}/>
<FieldError messages={settingsFieldErrors.theme ?? []}/>
<div className="space-y-3 rounded-xl p-4">
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="shrink-0">
<Label></Label>
@@ -545,6 +567,14 @@ const ThemeSection: FC<ThemeSectionProps> = (
</div>
<div className="space-y-3">
<div className="space-y-1">
<h3 className="text-sm font-semibold">使</h3>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
@@ -558,6 +588,9 @@ const ThemeSection: FC<ThemeSectionProps> = (
{`ライト ${ selection.value }`}
</option>))}
</select>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<div className="space-y-2">
@@ -572,16 +605,36 @@ const ThemeSection: FC<ThemeSectionProps> = (
{`ダーク ${ selection.value }`}
</option>))}
</select>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={onResetThemeSlot}>
</Button>
</div>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink
tag={previewTags.general}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>
</div>
<div className="space-y-4">
{themeTokenGroups.map (group => (
<div
key={group.title}
className="space-y-3 rounded-xl p-4">
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<h3 className="text-sm font-semibold">{group.title}</h3>
<div className="space-y-1">
{group.fields.map (field => (
@@ -604,7 +657,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
</div>
</div>))}
<div className="space-y-3 rounded-xl bg-background/70 p-4">
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium"></h3>
@@ -735,9 +788,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
state: SettingsTabDirtyState,
) => {
setDirtyStates (current => {
if (!(state.dirty) && current[tab] == null)
return current
const next = { ...current }
if (!(state.dirty))
@@ -830,7 +880,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}, nextThemeSlots)
}
const discardAllDirtyChanges = useCallback (async (): Promise<void> => {
const discardAllDirtyChanges = async (): Promise<void> => {
const currentDirtyStates = Object.values (dirtyStates)
for (const dirtyState of currentDirtyStates)
@@ -840,7 +890,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}
setDirtyStates ({ })
}, [dirtyStates])
}
const discardCurrentTabChanges = async (): Promise<void> => {
const currentDirtyState = dirtyStates[activeTab]
+5 -13
ファイルの表示
@@ -3,10 +3,6 @@ import { render } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter } from 'react-router-dom'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import type { ReactElement, ReactNode } from 'react'
type Options = {
@@ -19,25 +15,21 @@ export const renderWithProviders = (
) => {
const queryClient = new QueryClient ({
defaultOptions: {
queries: { retry: false } },
queries: { retry: false },
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<HelmetProvider>
<MemoryRouter initialEntries={[options.route ?? '/']}>
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
{children}
</MemoryRouter>
</HelmetProvider>
</QueryClientProvider>)
return {
queryClient,
...render (ui, { wrapper: Wrapper }) }
...render (ui, { wrapper: Wrapper }),
}
}
-7
ファイルの表示
@@ -20,10 +20,3 @@ window.requestAnimationFrame = callback => {
callback (0)
return 0
}
Object.defineProperty (window, 'matchMedia', {
configurable: true,
value: vi.fn ().mockImplementation (() => ({
addEventListener: vi.fn (),
removeEventListener: vi.fn (),
matches: false })) })