コミットを比較

...
3 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 2b7f52978f #455 2026-11-08 15:31:02 +09:00
みてるぞ dbae864f24 広場のタグ検索がスマホで利かない問題 (#435) (#453)
Reviewed-on: #453
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-08 14:52:38 +09:00
みてるぞ 8d00a6545e API の処理に時間がかかりすぎる問題 (#442) (#449)
Reviewed-on: #449
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-07 19:01:20 +09:00
26個のファイルの変更、1445行の追加、354行の削除
+2
ファイルの表示
@@ -71,3 +71,5 @@ gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'rails-i18n', '~> 8.0.0' gem 'rails-i18n', '~> 8.0.0'
gem "json", "~> 2.19.9"
+142 -134
ファイルの表示
@@ -1,29 +1,29 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (8.0.2) actioncable (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.0.2) actionmailbox (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
activejob (= 8.0.2) activejob (= 8.0.5.1)
activerecord (= 8.0.2) activerecord (= 8.0.5.1)
activestorage (= 8.0.2) activestorage (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.0.2) actionmailer (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
actionview (= 8.0.2) actionview (= 8.0.5.1)
activejob (= 8.0.2) activejob (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.0.2) actionpack (8.0.5.1)
actionview (= 8.0.2) actionview (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -31,35 +31,35 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.0.2) actiontext (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
activerecord (= 8.0.2) activerecord (= 8.0.5.1)
activestorage (= 8.0.2) activestorage (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.0.2) actionview (8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.0.2) activejob (8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.0.2) activemodel (8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
activerecord (8.0.2) activerecord (8.0.5.1)
activemodel (= 8.0.2) activemodel (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.0.2) activestorage (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
activejob (= 8.0.2) activejob (= 8.0.5.1)
activerecord (= 8.0.2) activerecord (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.0.2) activesupport (8.0.5.1)
base64 base64
benchmark (>= 0.3) benchmark (>= 0.3)
bigdecimal bigdecimal
@@ -92,22 +92,22 @@ GEM
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0) base64 (0.3.0)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
bcrypt_pbkdf (1.1.1-arm64-darwin) bcrypt_pbkdf (1.1.1-arm64-darwin)
bcrypt_pbkdf (1.1.1-x86_64-darwin) bcrypt_pbkdf (1.1.1-x86_64-darwin)
benchmark (0.4.0) benchmark (0.5.0)
bigdecimal (3.1.9) bigdecimal (4.1.3)
bootsnap (1.18.6) bootsnap (1.18.6)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.0.2) brakeman (7.0.2)
racc racc
builder (3.3.0) builder (3.3.0)
chronic (0.10.2) chronic (0.10.2)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.8)
connection_pool (2.5.3) connection_pool (3.0.2)
crass (1.0.6) crass (1.0.7)
date (3.4.1) date (3.6.0)
diff-lcs (1.6.2) diff-lcs (1.6.2)
discard (1.4.0) discard (1.4.0)
activerecord (>= 4.2, < 9.0) activerecord (>= 4.2, < 9.0)
@@ -115,8 +115,9 @@ GEM
dotenv-rails (3.1.8) dotenv-rails (3.1.8)
dotenv (= 3.1.8) dotenv (= 3.1.8)
railties (>= 6.1) railties (>= 6.1)
drb (2.2.1) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (6.0.7)
erubi (1.13.1) erubi (1.13.1)
factory_bot (6.5.6) factory_bot (6.5.6)
activesupport (>= 6.1.0) activesupport (>= 6.1.0)
@@ -134,7 +135,7 @@ GEM
gemojione (4.3.3) gemojione (4.3.3)
json json
github-markup (4.0.2) github-markup (4.0.2)
globalid (1.2.1) globalid (1.4.0)
activesupport (>= 6.1) activesupport (>= 6.1)
gollum (6.1.0) gollum (6.1.0)
gemojione (~> 4.1) gemojione (~> 4.1)
@@ -166,19 +167,20 @@ GEM
gollum-rugged_adapter (3.0) gollum-rugged_adapter (3.0)
mime-types (~> 3.4) mime-types (~> 3.4)
rugged (~> 1.5) rugged (~> 1.5)
i18n (1.14.7) i18n (1.15.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0) image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6) mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
io-console (0.8.0) io-console (0.9.4)
irb (1.15.2) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.12.0) json (2.19.9)
jwt (2.10.1) jwt (3.3.0)
base64 base64
kamal (2.6.1) kamal (2.6.1)
activesupport (>= 7.0) activesupport (>= 7.0)
@@ -198,15 +200,16 @@ GEM
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.24.1) loofah (2.25.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.9.1)
logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.0.4) marcel (1.2.1)
mime-types (3.7.0) mime-types (3.7.0)
logger logger
mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (~> 3.2025, >= 3.2025.0507)
@@ -215,45 +218,46 @@ GEM
benchmark benchmark
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.5) minitest (6.0.6)
msgpack (1.8.0) drb (~> 2.0)
multi_json (1.15.0) prism (~> 1.5)
msgpack (1.8.5)
multi_json (1.21.2)
mustache (1.1.1) mustache (1.1.1)
mustache-sinatra (2.0.0) mustache-sinatra (2.0.0)
mustache (~> 1.0) mustache (~> 1.0)
mustermann (3.0.3) mustermann (3.1.1)
ruby2_keywords (~> 0.0.1)
mysql2 (0.5.6) mysql2 (0.5.6)
net-imap (0.5.8) net-imap (0.6.8)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.4.0)
timeout timeout
net-scp (4.1.0) net-scp (4.1.0)
net-ssh (>= 2.6.5, < 8.0.0) net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0) net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0) net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1) net-smtp (0.5.2)
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.4) nio4r (2.7.5)
nokogiri (1.18.8-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-aarch64-linux-musl) nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm-linux-gnu) nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm-linux-musl) nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin) nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin) nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu) nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl) nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
octicons (19.15.2) octicons (19.15.2)
ostruct (0.6.1) ostruct (0.6.1)
@@ -261,70 +265,73 @@ GEM
parser (3.3.8.0) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pp (0.6.2) pp (0.6.4)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.1)
prism (1.4.0) prism (1.9.0)
psych (5.2.6) psych (5.5.0)
date date
stringio stringio
puma (6.6.0) puma (6.6.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.14) rack (3.2.7)
rack-cors (2.0.2) rack-cors (2.0.2)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-protection (4.1.1) rack-protection (4.2.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-session (2.1.1) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.3.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.0.2) rails (8.0.5.1)
actioncable (= 8.0.2) actioncable (= 8.0.5.1)
actionmailbox (= 8.0.2) actionmailbox (= 8.0.5.1)
actionmailer (= 8.0.2) actionmailer (= 8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
actiontext (= 8.0.2) actiontext (= 8.0.5.1)
actionview (= 8.0.2) actionview (= 8.0.5.1)
activejob (= 8.0.2) activejob (= 8.0.5.1)
activemodel (= 8.0.2) activemodel (= 8.0.5.1)
activerecord (= 8.0.2) activerecord (= 8.0.5.1)
activestorage (= 8.0.2) activestorage (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2) railties (= 8.0.5.1)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.1)
loofah (~> 2.21) loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.2) rails-i18n (8.0.2)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9) railties (>= 8.0.0, < 9)
railties (8.0.2) railties (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.4.2)
rdoc (6.13.1) rdoc (6.17.0)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
regexp_parser (2.10.0) tsort
reline (0.6.1) regexp_parser (2.13.1)
reline (0.7.0)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.4.1) rexml (3.4.4)
rouge (3.30.0) rouge (3.30.0)
rspec-core (3.13.6) rspec-core (3.13.6)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
@@ -345,20 +352,20 @@ GEM
rspec-support (3.13.6) rspec-support (3.13.6)
rss (0.3.1) rss (0.3.1)
rexml rexml
rubocop (1.75.6) rubocop (1.92.0)
json (~> 2.3) json (>= 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (>= 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.11.3, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0) rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.1) rubocop-ast (1.50.0)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.7)
rubocop-performance (1.25.0) rubocop-performance (1.25.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
@@ -377,24 +384,23 @@ GEM
ruby-vips (2.2.3) ruby-vips (2.2.3)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
ruby2_keywords (0.0.5)
rugged (1.9.0) rugged (1.9.0)
securerandom (0.4.1) securerandom (0.4.1)
sinatra (4.1.1) sinatra (4.2.1)
logger (>= 1.6.0) logger (>= 1.6.0)
mustermann (~> 3.0) mustermann (~> 3.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-protection (= 4.1.1) rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3) rack-session (>= 2.0.0, < 3)
tilt (~> 2.0) tilt (~> 2.0)
sinatra-contrib (4.1.1) sinatra-contrib (4.2.1)
multi_json (>= 0.0.2) multi_json (>= 0.0.2)
mustermann (~> 3.0) mustermann (~> 3.0)
rack-protection (= 4.1.1) rack-protection (= 4.2.1)
sinatra (= 4.1.1) sinatra (= 4.2.1)
tilt (~> 2.0) tilt (~> 2.0)
sprockets (4.2.2) sprockets (4.4.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.1)
logger logger
rack (>= 2.2.4, < 4) rack (>= 2.2.4, < 4)
sprockets-helpers (1.4.0) sprockets-helpers (1.4.0)
@@ -403,14 +409,14 @@ GEM
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sqlite3 (2.6.0-aarch64-linux-gnu) sqlite3 (2.10.1-aarch64-linux-gnu)
sqlite3 (2.6.0-aarch64-linux-musl) sqlite3 (2.10.1-aarch64-linux-musl)
sqlite3 (2.6.0-arm-linux-gnu) sqlite3 (2.10.1-arm-linux-gnu)
sqlite3 (2.6.0-arm-linux-musl) sqlite3 (2.10.1-arm-linux-musl)
sqlite3 (2.6.0-arm64-darwin) sqlite3 (2.10.1-arm64-darwin)
sqlite3 (2.6.0-x86_64-darwin) sqlite3 (2.10.1-x86_64-darwin)
sqlite3 (2.6.0-x86_64-linux-gnu) sqlite3 (2.10.1-x86_64-linux-gnu)
sqlite3 (2.6.0-x86_64-linux-musl) sqlite3 (2.10.1-x86_64-linux-musl)
sshkit (1.24.0) sshkit (1.24.0)
base64 base64
logger logger
@@ -418,18 +424,19 @@ GEM
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
ostruct ostruct
stringio (3.1.7) stringio (3.2.0)
therubyrhino (2.1.2) therubyrhino (2.1.2)
therubyrhino_jar (>= 1.7.4, < 1.7.9) therubyrhino_jar (>= 1.7.4, < 1.7.9)
therubyrhino_jar (1.7.8) therubyrhino_jar (1.7.8)
thor (1.3.2) thor (1.5.0)
thruster (0.1.13) thruster (0.1.13)
thruster (0.1.13-aarch64-linux) thruster (0.1.13-aarch64-linux)
thruster (0.1.13-arm64-darwin) thruster (0.1.13-arm64-darwin)
thruster (0.1.13-x86_64-darwin) thruster (0.1.13-x86_64-darwin)
thruster (0.1.13-x86_64-linux) thruster (0.1.13-x86_64-linux)
tilt (2.6.0) tilt (2.9.0)
timeout (0.4.3) timeout (0.6.1)
tsort (0.2.0)
twitter-text (1.14.7) twitter-text (1.14.7)
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
@@ -440,16 +447,16 @@ GEM
unicode-display_width (3.1.4) unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uri (1.0.3) uri (1.1.2)
useragent (0.16.11) useragent (0.16.11)
webrick (1.9.1) webrick (1.9.1)
websocket-driver (0.7.7) websocket-driver (0.8.2)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
whenever (1.0.0) whenever (1.0.0)
chronic (>= 0.6.3) chronic (>= 0.6.3)
zeitwerk (2.7.2) zeitwerk (2.8.3)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -473,6 +480,7 @@ DEPENDENCIES
factory_bot_rails factory_bot_rails
gollum gollum
image_processing (~> 1.14) image_processing (~> 1.14)
json (~> 2.19.9)
jwt jwt
kamal kamal
mysql2 (~> 0.5.6) mysql2 (~> 0.5.6)
+6 -2
ファイルの表示
@@ -14,8 +14,9 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
q = HistoryAccess.visible(version_scope) q = HistoryAccess.visible(PostVersion.all)
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
q = q.with_previous_version if tag_id || external_tag_id
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id))) if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' + q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " + "JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
@@ -28,9 +29,12 @@ class PostVersionsController < ApplicationController
count = q.except(:select, :order, :limit, :offset).count count = q.except(:select, :order, :limit, :offset).count
versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC')) ids = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.pluck('post_versions.id')
versions = version_scope.merge(q).where(id: ids)
.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(PostVersion) } render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(PostVersion) }
end end
+25 -14
ファイルの表示
@@ -37,20 +37,10 @@ class PostsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
pt_max_sql = needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?)
HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS max_updated_at')
.group('post_id')
.to_sql
updated_at_all_sql =
'GREATEST(posts.updated_at,' +
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
q = q =
filtered_posts(locale) filtered_posts(locale)
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:external_tags, :uploaded_user, :parents, :children, .preload(:external_tags, :uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials, post_tags: [:sections, { tag: [:deerjikists, :materials,
:tag_names, :tag_descriptions] }]) :tag_names, :tag_descriptions] }])
@@ -66,12 +56,25 @@ class PostsController < ApplicationController
end end
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0] q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1] q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
count_scope = q
if needs_history_join
history_times = HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS latest_at').group(:post_id)
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
'ON history_times.post_id = posts.id')
updated_at_all_sql = 'GREATEST(posts.updated_at, ' +
'COALESCE(history_times.latest_at, posts.updated_at))'
end
if updated_between[0] if updated_between[0]
q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0]) q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
end end
if updated_between[1] if updated_between[1]
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1]) q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end end
count_scope = q if updated_between.any?(&:present?)
if needs_history_join
q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
end
sort_sql = sort_sql =
case order[0] case order[0]
@@ -88,12 +91,20 @@ class PostsController < ApplicationController
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.to_a .to_a
latest_version_times =
if needs_history_join
{ }
else
HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id)))
.group(:post_id).maximum(:created_at)
end
q = q.except(:select, :order) count_scope = count_scope.except(:select, :order)
render json: { posts: posts.map { |post| render json: { posts: posts.map { |post|
updated_at = [post.updated_at, latest_version_times[post.id]].compact.max
PostRepr.base(post, language_code: locale.language_code) PostRepr.base(post, language_code: locale.language_code)
.merge(updated_at: post.updated_at_all).tap do |json| .merge('updated_at' => updated_at).tap do |json|
json['thumbnail'] = json['thumbnail'] =
if post.thumbnail.attached? if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false) rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -101,7 +112,7 @@ class PostsController < ApplicationController
nil nil
end end
end end
}, count: q.group_values.present? ? q.count.size : q.count } }, count: count_scope.group_values.present? ? count_scope.count.size : count_scope.count }
end end
def random def random
+14 -9
ファイルの表示
@@ -59,20 +59,21 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
end end
def self.json_reference(table, key, document) def self.json_reference(table, key, document)
"NOT EXISTS (SELECT 1 FROM JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), " + # A scalar lookup avoids MySQL's semijoin rewrite losing the JSON row correlation.
"(SELECT 1 FROM JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), " +
"'$[*]' COLUMNS (reference_id BIGINT PATH '$' NULL ON EMPTY)) reference_entry " + "'$[*]' COLUMNS (reference_id BIGINT PATH '$' NULL ON EMPTY)) reference_entry " +
"JOIN (#{ discarded_keys(table, key) }) blocked " + "JOIN (#{ discarded_keys(table, key) }) blocked " +
"ON reference_entry.reference_id = blocked.#{ key })" "ON reference_entry.reference_id = blocked.#{ key } LIMIT 1) IS NULL"
end end
def self.list_reference(table, key, document) def self.list_reference(table, key, document)
# Preserve FIND_IN_SET's exact token semantics, including empty tokens. # Preserve FIND_IN_SET's exact token semantics, including empty tokens.
array = "CONCAT('[', REPLACE(JSON_QUOTE(REPLACE(COALESCE(#{ document }, ''), " + array = "CONCAT('[', REPLACE(JSON_QUOTE(REPLACE(COALESCE(#{ document }, ''), " +
"' ', ',')), ',', '\",\"'), ']')" "' ', ',')), ',', '\",\"'), ']')"
"NOT EXISTS (SELECT 1 FROM JSON_TABLE(#{ array }, '$[*]' " + "(SELECT 1 FROM JSON_TABLE(#{ array }, '$[*]' " +
"COLUMNS (reference_id VARCHAR(255) PATH '$')) reference_entry " + "COLUMNS (reference_id VARCHAR(255) PATH '$')) reference_entry " +
"JOIN (#{ discarded_keys(table, key) }) blocked " + "JOIN (#{ discarded_keys(table, key) }) blocked " +
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR))" "ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR) LIMIT 1) IS NULL"
end end
def self.snapshot_reference(model, row, external: false) def self.snapshot_reference(model, row, external: false)
@@ -91,8 +92,8 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
match += ' AND reference_version.version_no = tag_reference.version_no' match += ' AND reference_version.version_no = tag_reference.version_no'
latest = '' latest = ''
end end
"NOT EXISTS (SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " + "(SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest })" "WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest } LIMIT 1) IS NULL"
end end
def self.reference_sql(table, row) def self.reference_sql(table, row)
@@ -109,11 +110,15 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
# conservatively; never expose a private linked name through an external tag. # conservatively; never expose a private linked name through an external tag.
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " + names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))" "COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
["NOT EXISTS (SELECT 1 FROM tag_versions private_tag " + # DISTINCT prevents merging this independent name set into the correlated scan.
# Keep byte-distinct names even when the column collation considers them equal.
private_names = 'SELECT DISTINCT private_name.name, ' +
'CAST(private_name.name AS BINARY) AS name_bytes FROM tag_versions private_tag ' +
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " + "JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
"ON blocked.tag_id = private_tag.tag_id " + 'ON blocked.tag_id = private_tag.tag_id ' +
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " + "JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
"WHERE private_name.name <> '' AND " + "WHERE private_name.name <> ''"
["NOT EXISTS (SELECT 1 FROM (#{ private_names }) private_name WHERE " +
"LOCATE(CONCAT(' ', private_name.name, ' '), " + "LOCATE(CONCAT(' ', private_name.name, ' '), " +
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"] "CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
when 'tag_versions' when 'tag_versions'
+129
ファイルの表示
@@ -0,0 +1,129 @@
require 'rails_helper'
RSpec.describe 'Post history privacy', type: :request do
let(:origin) { Time.utc(2020, 1, 1) }
let!(:post_record) do
create(:post, title: 'current', created_at: origin, updated_at: origin)
end
let!(:other_post) do
create(:post, created_at: origin, updated_at: origin + 3.days + 1.hour)
end
let!(:tag) { create(:tag, primary_name: 'private-history-tag') }
let!(:external) { create(:external_tag, name: 'external-history-tag') }
before do
['create', 'discard'].each_with_index do |event, index|
TagVersion.create!(tag:, version_no: index + 1, event_type: event,
name: 'private-history-tag', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'private-history-tag', script_code: 'Jpan',
auto_generated: false, aliases: [] } },
created_at: origin + index.hours)
end
NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
name: 'nico:external-history-tag', linked_tags: 'private-history-tag',
created_at: origin)
['create', 'discard', 'restore', 'update'].each_with_index do |event, index|
snapshots =
if index < 2
[{ tag_id: tag.id, version_no: 1, name: 'private-history-tag', sections: [] }]
elsif index == 3
[{ external_tag_id: external.id }]
else
[]
end
PostVersion.create!(post: post_record, version_no: index + 1, event_type: event,
title: "version #{ index + 1 }", url: post_record.url, tags: '',
tags_json: snapshots, parent_post_ids: '', created_at: origin + (index + 1).days)
end
end
it 'evaluates each reference condition against the restored snapshot alone' do
HistoryAccess.set(user: nil) do
restored = PostVersion.where(post_id: post_record.id, version_no: 3)
HistoryAccess.reference_sql('post_versions', 'post_versions').each do |condition|
expect(restored.where(Arel.sql(condition)).pluck(:version_no)).to eq([3])
end
expect(HistoryAccess.visible(PostVersion.where(post_id: post_record.id))
.pluck(:version_no)).to eq([3])
end
end
it 'keeps every version through a later discard private after another restore' do
['discard', 'restore'].each_with_index do |event, index|
PostVersion.create!(post: post_record, version_no: index + 5, event_type: event,
title: 'later snapshot', url: post_record.url, tags: '', tags_json: [],
parent_post_ids: '', created_at: origin + (index + 5).days)
end
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
expect(response.parsed_body.fetch('versions').pluck('version_no')).to eq([6])
expect(response.parsed_body.fetch('count')).to eq(1)
expect(response.parsed_body.fetch('versions').first.fetch('title').fetch('prev')).to be_nil
end
['anonymous', 'member', 'admin'].each do |role|
context "as #{ role }" do
before do
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end
it 'uses only authorised history timestamps for display' do
get '/posts'
expect(response).to have_http_status(:ok)
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id }
expected = origin + (role == 'admin' ? 4 : 3).days
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end
it 'uses the same visibility for date bounds and count' do
get '/posts', params: { updated_from: (origin + 4.days).iso8601,
updated_to: (origin + 4.days).iso8601 }
expect(response).to have_http_status(:ok)
ids = response.parsed_body.fetch('posts').pluck('id')
expect(ids).to eq(role == 'admin' ? [post_record.id] : [])
expect(response.parsed_body.fetch('count')).to eq(ids.length)
end
it 'sorts using authorised timestamps in both directions' do
['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok)
expected = role == 'admin' ? [other_post.id, post_record.id] :
[post_record.id, other_post.id]
expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end
end
it 'applies authorised timestamp ordering before pagination' do
get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok)
expected = role == 'admin' ? post_record.id : other_post.id
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([expected])
expect(response.parsed_body.fetch('count')).to eq(2)
end
it 'counts only visible versions and masks a private previous version' do
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
body = response.parsed_body
expected = role == 'admin' ? [4, 3, 2, 1] : [3]
expect(body.fetch('count')).to eq(expected.length)
expect(body.fetch('versions').pluck('version_no')).to eq(expected)
restored = body.fetch('versions').find { _1.fetch('version_no') == 3 }
expect(restored.fetch('title').fetch('prev')).to eq(role == 'admin' ? 'version 2' : nil)
end
it 'uses only an authorised previous version for tag filtering' do
get '/posts/versions', params: { post: post_record.id, tag: tag.id }
expect(response).to have_http_status(:ok)
expected = role == 'admin' ? [3, 2, 1] : []
expect(response.parsed_body.fetch('versions').pluck('version_no')).to eq(expected)
expect(response.parsed_body.fetch('count')).to eq(expected.length)
end
end
end
end
+41
ファイルの表示
@@ -629,6 +629,37 @@ RSpec.describe 'Posts API', type: :request do
create(:post_tag, post: plain_post, tag:, created_at: t3) create(:post_tag, post: plain_post, tag:, created_at: t3)
end end
it 'displays history time after pagination without joining history to posts or count' do
queries = []
subscriber = ->(event) { queries << event.payload.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
get '/posts', params: { url: '/version-time/', order: 'url:asc', limit: 1 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').pluck('id')).to eq([history_post.id])
expect(Time.zone.parse(json.fetch('posts').first.fetch('updated_at'))).to eq(t2)
expect(json.fetch('count')).to eq(3)
post_queries = queries.select { _1.include?('FROM `posts`') }
expect(post_queries).not_to be_empty
expect(post_queries.grep(/post_versions/)).to be_empty
end
it 'omits the history aggregate from count when only sorting by update time' do
queries = []
subscriber = ->(event) { queries << event.payload.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
get '/posts', params: { url: '/version-time/', order: 'updated_at:desc', limit: 1 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').pluck('id')).to eq([newer_post.id])
expect(json.fetch('count')).to eq(3)
count_queries = queries.select { _1.include?('FROM `posts`') && _1.match?(/COUNT\(/i) }
expect(count_queries).not_to be_empty
expect(count_queries.grep(/post_versions/)).to be_empty
end
['asc', 'desc'].each do |direction| ['asc', 'desc'].each do |direction|
it "sorts by the later of post update and latest version time (#{ direction })" do it "sorts by the later of post update and latest version time (#{ direction })" do
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" } get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
@@ -658,6 +689,16 @@ RSpec.describe 'Posts API', type: :request do
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id]) expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
expect(json.fetch('count')).to eq(1) expect(json.fetch('count')).to eq(1)
end end
it 'counts every timestamp match before pagination' do
get '/posts', params: { url: '/version-time/', order: 'updated_at:asc',
updated_from: t1.iso8601, updated_to: t3.iso8601,
limit: 1, page: 2 }
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').pluck('id')).to eq([history_post.id])
expect(json.fetch('count')).to eq(3)
end
end end
context 'when original_created_from/original_created_to are provided' do context 'when original_created_from/original_created_to are provided' do
+8 -2
ファイルの表示
@@ -221,9 +221,15 @@ RSpec.describe CountsExportDownload::ResponseBody do
body = described_class.new(source) { |state| states << state; true } body = described_class.new(source) { |state| states << state; true }
response = ActionDispatch::Response.new response = ActionDispatch::Response.new
response.body = body response.body = body
deferred = response.stream.to_ary
_, _, rack_body = response.to_a
expect(states).to eq([]) expect(states).to eq([])
expect(deferred.to_a.join).to eq(payload)
chunks = []
rack_body.each { |chunk| chunks << chunk }
expect(chunks.join).to eq(payload)
expect(states.count('started')).to eq(1) expect(states.count('started')).to eq(1)
end end
end end
+94
ファイルの表示
@@ -0,0 +1,94 @@
Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts)
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
バイナリファイルは表示されません。
+11
ファイルの表示
@@ -0,0 +1,11 @@
Noto Sans Mongolian Regular, unmodified TrueType font.
Font copyright: Copyright 2018 Google Inc. All Rights Reserved.
- Source: https://github.com/notofonts/noto-fonts/tree/main/hinted/ttf/NotoSansMongolian
- Font: https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansMongolian/NotoSansMongolian-Regular.ttf
- License: SIL Open Font License 1.1; see the accompanying LICENSE.
- Retrieved: 2026-11-08.
Used only by the Unicode Mongolian sample in the development layout route.
The font and license are served together from this directory.
+16 -11
ファイルの表示
@@ -20,13 +20,13 @@ import { Toaster } from '@/components/ui/toaster'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import { initialiseUserSession } from '@/lib/users' import { initialiseUserSession } from '@/lib/users'
import { prepareHelpPage } from '@/lib/help' import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, legacyLocalePath, import { fetchLocales, isLocaleCode, isVerticalPreviewPath, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales' localiseTo, switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys' import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
import { isCountsPath } from '@/lib/routeLocales' import { isCountsPath } from '@/lib/routeLocales'
import { switchPostFilterLocale } from '@/lib/posts' import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki' import { switchWikiLocale } from '@/lib/wiki'
import { useLocale } from '@/lib/useLocale' import { useLocale, useVerticalPreview } from '@/lib/useLocale'
import { applyClientAnimationMode, import { applyClientAnimationMode,
applyClientAppearance, applyClientAppearance,
fetchUserThemeSlots, fetchUserThemeSlots,
@@ -267,6 +267,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const client = useQueryClient () const client = useQueryClient ()
const location = useLocation () const location = useLocation ()
const locale = useLocale () const locale = useLocale ()
const verticalPreview = useVerticalPreview ()
const route = resolvePageRoute (location.pathname) const route = resolvePageRoute (location.pathname)
const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots
const pagePath = unlocalisedPath (location.pathname) const pagePath = unlocalisedPath (location.pathname)
@@ -393,7 +394,8 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
<motion.div <motion.div
layout={(animationMode === 'off' || countsPage) ? false : 'position'} layout={(animationMode === 'off' || countsPage) ? false : 'position'}
transition={{ layout: appLayoutTransition }} transition={{ layout: appLayoutTransition }}
className="relative flex h-dvh w-full flex-col overflow-y-hidden"> data-vertical-preview={verticalPreview || undefined}
className="app-layout relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/> <TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
<RouteTransitionWrapper <RouteTransitionWrapper
user={user} user={user}
@@ -412,6 +414,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const LocaleRoute: FC = () => { const LocaleRoute: FC = () => {
const { locale } = useParams () const { locale } = useParams ()
const location = useLocation () const location = useLocation ()
const verticalPreview = isVerticalPreviewPath (location.pathname)
const { i18n } = useTranslation () const { i18n } = useTranslation ()
const { data, isError } = useQuery ({ const { data, isError } = useQuery ({
queryKey: localesKeys.index, queryKey: localesKeys.index,
@@ -421,10 +424,11 @@ const LocaleRoute: FC = () => {
refetchOnMount: true, refetchOnMount: true,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
refetchOnReconnect: true }) refetchOnReconnect: true })
const contentLocale = verticalPreview ? 'ja' : locale
const activeLocale = const activeLocale =
(isLocaleCode (locale) (isLocaleCode (contentLocale)
&& data?.locales.some (entry => entry.code === locale)) && data?.locales.some (entry => entry.code === contentLocale))
? locale ? contentLocale
: null : null
useLayoutEffect (() => { useLayoutEffect (() => {
@@ -435,15 +439,15 @@ const LocaleRoute: FC = () => {
}, [activeLocale, i18n]) }, [activeLocale, i18n])
const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null) const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null)
if (!(isLocaleCode (locale)) && redirect == null) if (!(isLocaleCode (locale)) && !verticalPreview && redirect == null)
return <NotFound/> return <NotFound/>
if (isError && data == null) if (isError && data == null)
return <ServiceUnavailable/> return <ServiceUnavailable/>
if (data == null) if (data == null)
return null return null
if (isLocaleCode (locale)) if (isLocaleCode (locale) || verticalPreview)
{ {
return data.locales.some (entry => entry.code === locale) return data.locales.some (entry => entry.code === contentLocale)
? <RoutedApp locales={data.locales}/> ? <RoutedApp locales={data.locales}/>
: <NotFound/> : <NotFound/>
} }
@@ -455,7 +459,8 @@ const LocaleRoute: FC = () => {
const LocaleRedirect = ({ to }: { to: string }) => { const LocaleRedirect = ({ to }: { to: string }) => {
const locale = useLocale () const locale = useLocale ()
const location = useLocation () const location = useLocation ()
return <Navigate to={switchLocalePath ({ ...location, pathname: to }, locale)} replace/> return <Navigate to={localiseTo (
{ ...location, pathname: to }, locale, location.pathname)} replace/>
} }
+1 -1
ファイルの表示
@@ -34,7 +34,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
const cardRef = useRef<HTMLDivElement> (null) const cardRef = useRef<HTMLDivElement> (null)
return ( return (
<div className="flex flex-wrap gap-6 p-4"> <div className="post-list flex flex-wrap gap-6 p-4">
{posts.map ((post, i) => { {posts.map ((post, i) => {
const sharedId = `page-${ post.id }` const sharedId = `page-${ post.id }`
const layoutId = sharedId const layoutId = sharedId
+9 -5
ファイルの表示
@@ -1,9 +1,10 @@
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { forwardRef, useMemo } from 'react' import { forwardRef, useMemo } from 'react'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { createPath } from 'react-router-dom' import { createPath, useLocation } from 'react-router-dom'
import { localiseTo } from '@/lib/locales' import { isVerticalPreviewPath, localeFromPath,
localiseTo, unlocalisedPath } from '@/lib/locales'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers' import { prefetchForURL } from '@/lib/prefetchers'
@@ -38,15 +39,18 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
const locale = useLocale () const locale = useLocale ()
const { pathname } = useLocation ()
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo ( const path = useMemo (
() => { () => {
const localised = localiseTo (to, locale) const localised = localiseTo (to, locale, pathname)
return typeof localised === 'string' ? localised : createPath (localised) const destination = typeof localised === 'string' ? localised : createPath (localised)
return (isVerticalPreviewPath (pathname) && localeFromPath (destination) === locale)
? `/mn-Mong${ unlocalisedPath (destination) }` : destination
}, },
[to, locale], [to, locale, pathname],
) )
const url = useMemo (() => { const url = useMemo (() => {
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString () return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
+120
ファイルの表示
@@ -0,0 +1,120 @@
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagSearch from '@/components/TagSearch'
import * as api from '@/lib/api'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const LocationOutput = () => {
const location = useLocation ()
return <output data-testid="location">{location.pathname}{location.search}</output>
}
const renderSearch = () => renderWithProviders (
<>
<TagSearch/>
<LocationOutput/>
</>, { route: '/ja/posts?page=3' })
describe ('TagSearch', () => {
beforeEach (() => {
vi.spyOn (api, 'apiGet').mockResolvedValue ([])
})
it ('searches through native submission without an Enter key event', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.change (input, { target: { value: '虹夏 not:TEST type:or' } })
expect (input.form).toBeInstanceOf (HTMLFormElement)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏 not:test', match: 'any' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
expect (input).toHaveValue ('虹夏 not:test type:or')
})
it ('requests a search action from the software keyboard', () => {
renderSearch ()
expect (screen.getByRole ('textbox')).toHaveAttribute ('enterkeyhint', 'search')
})
it ('searches through the form when Enter is pressed', async () => {
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹夏{Enter}')
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it.each ([
{ label: 'composition flag', composing: true, keyCode: 13 },
{ label: 'legacy IME code', composing: false, keyCode: 229 }]) (
'cancels confirmation Enter with $label', ({ composing, keyCode }) => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: composing, keyCode })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('cancels Enter throughout composition even without keyboard IME flags', () => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: false, keyCode: 13 })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('blocks direct submission during composition and allows it after confirmation', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
fireEvent.submit (input.form!)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
fireEvent.compositionEnd (input)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it ('selects the active suggestion without submitting the form', async () => {
vi.mocked (api.apiGet).mockResolvedValue ([buildTag ({ name: '虹夏' })])
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹')
await screen.findByText ('虹夏')
await user.keyboard ('{ArrowDown}{Enter}')
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏 ')
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
})
})
+44 -24
ファイルの表示
@@ -1,6 +1,6 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する. // TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
@@ -10,7 +10,7 @@ import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox' import TagSearchBox from './TagSearchBox'
import type { ChangeEvent, FC, KeyboardEvent } from 'react' import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -20,6 +20,7 @@ const TagSearch: FC = () => {
const locale = useLocale () const locale = useLocale ()
const location = useLocation () const location = useLocation ()
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
const compositionFlgRef = useRef (false)
const [activeIndex, setActiveIndex] = useState (-1) const [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('') const [search, setSearch] = useState ('')
@@ -74,7 +75,34 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
}, [locale]) }, [locale])
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
if (compositionFlgRef.current)
return
const parts = search.split (' ')
const match =
parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match })).toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
if (compositionFlgRef.current
|| ev.nativeEvent.isComposing
|| ev.nativeEvent.keyCode === 229)
{
if (ev.key === 'Enter')
ev.preventDefault ()
return
}
switch (ev.key) switch (ev.key)
{ {
case 'ArrowDown': case 'ArrowDown':
@@ -90,13 +118,12 @@ const TagSearch: FC = () => {
break break
case 'Enter': case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
{ {
const selected = suggestions[activeIndex] const selected = suggestionsVsbl ? suggestions[activeIndex] : null
if (selected) if (selected == null)
handleTagSelect (selected) break
ev.preventDefault ()
handleTagSelect (selected)
} }
break break
@@ -105,20 +132,6 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
break break
} }
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
const parts = search.split (' ')
const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match }))
.toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
} }
const handleTagSelect = (tag: Tag) => { const handleTagSelect = (tag: Tag) => {
@@ -144,8 +157,9 @@ const TagSearch: FC = () => {
}, [location.search]) }, [location.search])
return ( return (
<div className="relative w-full"> <form onSubmit={handleSubmit} className="relative w-full">
<input type="text" <input type="text"
enterKeyHint="search"
data-shortcut-focus-search="true" data-shortcut-focus-search="true"
placeholder={message ('labels.tagSearch').text} placeholder={message ('labels.tagSearch').text}
value={search} value={search}
@@ -153,12 +167,18 @@ const TagSearch: FC = () => {
onFocus={() => setSuggestionsVsbl (true)} onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)} onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onCompositionStart={() => {
compositionFlgRef.current = true
}}
onCompositionEnd={() => {
compositionFlgRef.current = false
}}
className={inputClass (false, className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/> 'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]} <TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex} activeIndex={activeIndex}
onSelect={handleTagSelect}/> onSelect={handleTagSelect}/>
</div>) </form>)
} }
export default TagSearch export default TagSearch
+65 -27
ファイルの表示
@@ -4,7 +4,7 @@ import { Globe } from 'lucide-react'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { useLocale, useLocaleNavigate, useVerticalPreview } from '@/lib/useLocale'
import LocalisedText, { formatUIString } from '@/components/LocalisedText' import LocalisedText, { formatUIString } from '@/components/LocalisedText'
import Separator from '@/components/MenuSeparator' import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
@@ -137,6 +137,7 @@ export const menuOutline = (
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => { const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const { message, label } = useUITranslation ('shared') const { message, label } = useUITranslation ('shared')
const locale = useLocale () const locale = useLocale ()
const verticalPreview = useVerticalPreview ()
const localeName = locales.find (entry => entry.code === locale)?.name const localeName = locales.find (entry => entry.code === locale)?.name
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
const location = useLocation () const location = useLocation ()
@@ -180,11 +181,12 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
let node: HTMLElement | null = el let node: HTMLElement | null = el
while (node != null && node !== nav) while (node != null && node !== nav)
{ {
left += node.offsetLeft left += verticalPreview ? node.offsetTop : node.offsetLeft
const parent: Element | null = node.offsetParent const parent: Element | null = node.offsetParent
node = parent instanceof HTMLElement ? parent : null node = parent instanceof HTMLElement ? parent : null
} }
const width = node === nav ? el.offsetWidth : 0 const width = node === nav
? (verticalPreview ? el.offsetHeight : el.offsetWidth) : 0
const visible = width > 0 const visible = width > 0
setHL (current => setHL (current =>
(current.left === left && current.width === width && current.visible === visible) (current.left === left && current.width === width && current.visible === visible)
@@ -202,6 +204,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const [moreVsbl, setMoreVsbl] = useState (false) const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1) const [openItemIdx, setOpenItemIdx] = useState (-1)
const [submenuHeight, setSubmenuHeight] = useState (0) const [submenuHeight, setSubmenuHeight] = useState (0)
const [submenuWidth, setSubmenuWidth] = useState (0)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
const wikiIdStr = String (wikiId ?? '') const wikiIdStr = String (wikiId ?? '')
@@ -255,8 +258,22 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
animationsOff animationsOff
? undefined ? undefined
: (reducedAnimations : (reducedAnimations
? 'transition-[transform,width] duration-75 ease-linear' ? 'transition-[transform,width,height] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out') : 'transition-[transform,width,height] duration-200 ease-out')
const submenuItemMotion = animationsOff ? { } : {
initial: { x: verticalPreview ? -40 : 0,
y: verticalPreview ? 0 : -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: verticalPreview ? -40 : 0,
y: verticalPreview ? 0 : -40, opacity: 0 } }
const submenuVariants = {
enter: (direction: -1 | 1) => ({
x: verticalPreview ? direction * 24 : 0,
y: verticalPreview ? 0 : direction * 24, opacity: 0 }),
centre: { x: 0, y: 0, opacity: 1 },
exit: (direction: -1 | 1) => ({
x: verticalPreview ? (-direction) * 24 : 0,
y: verticalPreview ? 0 : (-direction) * 24, opacity: 0 }) }
const prevActiveIdxRef = useRef<number> (activeIdx) const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -287,6 +304,14 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
return return
} }
if (verticalPreview)
{
setDesktop (true)
setDensity ('auto')
measure (moreVsbl ? -1 : activeIdx)
return
}
// Measure the same desktop elements while the mobile layout is active. // Measure the same desktop elements while the mobile layout is active.
layout.setAttribute ('data-measuring', '') layout.setAttribute ('data-measuring', '')
const previousDensity = layout.getAttribute ('data-density') ?? 'auto' const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
@@ -351,7 +376,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
removeEventListener ('resize', schedule) removeEventListener ('resize', schedule)
document.fonts?.removeEventListener ('loadingdone', schedule) document.fonts?.removeEventListener ('loadingdone', schedule)
} }
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels]) }, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales,
menuLabels, verticalPreview])
useEffect (() => { useEffect (() => {
if (desktop == null || layoutReady) if (desktop == null || layoutReady)
@@ -378,7 +404,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
measure (moreVsbl ? -1 : activeIdx) measure (moreVsbl ? -1 : activeIdx)
if (!(desktop)) if (!(desktop))
setMoreVsbl (false) setMoreVsbl (false)
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels]) }, [activeIdx, moreVsbl, desktop, density, locale, animationMode,
menuLabels, verticalPreview])
useEffect (() => { useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId) const unsubscribe = WikiIdBus.subscribe (setWikiId)
@@ -392,6 +419,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const update = () => { const update = () => {
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height)) setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
setSubmenuWidth (Math.ceil (content.getBoundingClientRect ().width))
} }
const observer = const observer =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update) typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
@@ -403,7 +431,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
observer?.disconnect () observer?.disconnect ()
removeEventListener ('resize', update) removeEventListener ('resize', update)
} }
}, [activeIdx, moreVsbl, desktop, density, locale]) }, [activeIdx, moreVsbl, desktop, density, locale, verticalPreview])
useEffect (() => { useEffect (() => {
setMenuOpen (false) setMenuOpen (false)
@@ -479,8 +507,11 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
className={cn ('absolute inset-y-0 h-12', className={cn ('absolute inset-y-0 h-12',
'top-nav-highlight', 'top-nav-highlight',
highlightTransitionClass)} highlightTransitionClass)}
style={{ width: hl.width, style={{ width: verticalPreview ? '100%' : hl.width,
transform: `translateX(${ hl.left }px)`, height: verticalPreview ? hl.width : undefined,
transform: verticalPreview
? `translateY(${ hl.left }px)`
: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/> opacity: hl.visible ? 1 : 0 }}/>
{visibleMenu.map ((item, i) => ( {visibleMenu.map ((item, i) => (
@@ -529,10 +560,18 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2"> <div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
<TopNavUser user={user}/> <TopNavUser user={user}/>
{localeSelector} {localeSelector}
{verticalPreview && (
<span className="vertical-preview-sample" lang="mn-Mong">
ᠮᠣᠩᠭᠣᠯ
</span>)}
</div> </div>
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2"> <div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
{menuOpen && localeSelector} {menuOpen && localeSelector}
{verticalPreview && (
<span className="vertical-preview-sample" lang="mn-Mong">
ᠮᠣᠩᠭᠣᠯ
</span>)}
<button <button
type="button" type="button"
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
@@ -554,7 +593,9 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
layout layout
className="top-nav-desktop-submenu relative z-20 shrink-0 className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu" overflow-hidden top-nav-submenu"
animate={{ height: desktop == null ? 'auto' : submenuHeight }} animate={verticalPreview && desktop
? { width: submenuWidth, height: '100%' }
: { width: 'auto', height: desktop == null ? 'auto' : submenuHeight }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
setMoreVsbl (false) setMoreVsbl (false)
@@ -566,7 +607,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
onLayoutAnimationComplete={() => { onLayoutAnimationComplete={() => {
measure (highlightTargetRef.current) measure (highlightTargetRef.current)
}}> }}>
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}> <div ref={submenuContentRef}
className={cn ('top-nav-submenu-content', moreVsbl && 'top-nav-more-menu')}>
{moreVsbl {moreVsbl
? ( ? (
moreMenu.map ((item, i) => ( moreMenu.map ((item, i) => (
@@ -576,9 +618,11 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
{...((item.visible ?? true) {...((item.visible ?? true)
? { layoutId: `menu-${ menu.indexOf (item) }` } ? { layoutId: `menu-${ menu.indexOf (item) }` }
: (animationsOff ? { } : (animationsOff ? { }
: { initial: { x: 40, y: -40, opacity: 0 }, : { initial: {
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 }, animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))} exit: {
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 } }))}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)} onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
className="z-10 min-w-0 px-3 py-2 font-bold"> className="z-10 min-w-0 px-3 py-2 font-bold">
<h2> <h2>
@@ -599,10 +643,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
transition={topNavTransition} transition={topNavTransition}
{...(visibleMenu[activeIdx] === item {...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` } ? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: (animationsOff ? { } : submenuItemMotion)}>
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{subItem.component} {subItem.component}
</motion.div>) </motion.div>)
: ( : (
@@ -612,10 +653,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
transition={topNavTransition} transition={topNavTransition}
{...(visibleMenu[activeIdx] === item {...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` } ? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: (animationsOff ? { } : submenuItemMotion)}>
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink <PrefetchLink
to={subItem.to} to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined} target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
@@ -633,7 +671,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<div className="relative min-h-[40px]"> <div className="relative min-h-[40px]">
{animationsOff {animationsOff
? ( ? (
<div className="flex min-h-[40px] flex-wrap items-center px-3"> <div className="top-nav-submenu-items flex min-h-[40px]
flex-wrap items-center px-3">
{(visibleMenu[activeIdx]?.subMenu ?? []) {(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true) .filter (item => item.visible ?? true)
.map ((item, i) => ( .map ((item, i) => (
@@ -662,10 +701,9 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<motion.div <motion.div
key={activeIdx} key={activeIdx}
custom={dir} custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }), variants={submenuVariants}
centre: { y: 0, opacity: 1 }, className="top-nav-submenu-items relative flex min-h-[40px]
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }} flex-wrap items-center px-3"
className="relative flex min-h-[40px] flex-wrap items-center px-3"
initial="enter" initial="enter"
animate="centre" animate="centre"
exit="exit" exit="exit"
+58 -5
ファイルの表示
@@ -1,7 +1,10 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { clientAnimationTransition } from '@/lib/clientAnimation' import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useVerticalPreview } from '@/lib/useLocale'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
@@ -9,21 +12,71 @@ import type { FC, ReactNode } from 'react'
type Props = { type Props = {
children: ReactNode children: ReactNode
className?: string className?: string
animateLayout?: boolean } animateLayout?: boolean
verticalContent?: boolean }
const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => { const MainArea: FC<Props> = (
{ children, className, animateLayout = true, verticalContent = false },
) => {
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const verticalPreview = useVerticalPreview ()
const horizontal = verticalPreview && verticalContent
const { pathname, search } = useLocation ()
const rootRef = useRef<HTMLElement | null> (null)
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition ( const layoutTransition = clientAnimationTransition (
animationMode, animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } }, { normal: { duration: .2, ease: 'easeOut' as const } })
)
useEffect (() => {
if (horizontal && rootRef.current != null)
rootRef.current.scrollLeft = 0
}, [horizontal, pathname, search])
useEffect (() => {
const root = rootRef.current
if (!horizontal || root == null)
return
const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey || event.shiftKey
|| event.deltaX !== 0 || event.deltaY === 0 || event.defaultPrevented)
return
// Nested tables, forms and code blocks retain their own scrolling.
let node = event.target instanceof HTMLElement ? event.target : null
while (node != null && node !== root)
{
if (node.matches ('input, select, textarea, [contenteditable="true"]'))
return
const style = getComputedStyle (node)
if ((/auto|scroll/.test (style.overflowY) && node.scrollHeight > node.clientHeight)
|| (/auto|scroll/.test (style.overflowX) && node.scrollWidth > node.clientWidth))
return
node = node.parentElement
}
const unit = event.deltaMode === 1 ? 16 : (event.deltaMode === 2 ? root.clientWidth : 1)
const delta = event.deltaY * unit
if ((delta < 0 && root.scrollLeft <= 0)
|| (delta > 0 && root.scrollLeft + root.clientWidth >= root.scrollWidth))
return
event.preventDefault ()
root.scrollLeft += delta
}
root.addEventListener ('wheel', handleWheel, { passive: false })
return () => root.removeEventListener ('wheel', handleWheel)
}, [horizontal])
return ( return (
<motion.main <motion.main
ref={rootRef}
data-vertical-content={horizontal || undefined}
tabIndex={horizontal ? 0 : undefined}
transition={{ layout: layoutTransition }} transition={{ layout: layoutTransition }}
className={cn ('flex-1 overflow-y-auto p-4', className)} className={cn ('main-area flex-1 overflow-y-auto p-4', className)}
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}> layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
{children} {children}
</motion.main>) </motion.main>)
+126 -103
ファイルの表示
@@ -1,13 +1,14 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { clientAnimationTransition } from '@/lib/clientAnimation' import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import { useVerticalPreview } from '@/lib/useLocale'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react' import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
type SidebarSide = 'left' | 'right' type SidebarSide = 'left' | 'right'
@@ -23,12 +24,12 @@ type Props = {
type ResizeState = { type ResizeState = {
pointerId: number pointerId: number
startWidth: number startSize: number
startX: number startX: number
startY: number
verticalPreview: boolean
side: SidebarSide } side: SidebarSide }
type PendingPressState = ResizeState & { startY: number }
const DEFAULT_SIDEBAR_WIDTH = 256 const DEFAULT_SIDEBAR_WIDTH = 256
const MIN_SIDEBAR_WIDTH = 192 const MIN_SIDEBAR_WIDTH = 192
const MIN_MAIN_WIDTH = 360 const MIN_MAIN_WIDTH = 360
@@ -37,84 +38,97 @@ const LONG_PRESS_DELAY_MS = 350
const TOUCH_MOVE_THRESHOLD_PX = 8 const TOUCH_MOVE_THRESHOLD_PX = 8
const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string => const storageKeyForSidebar = (
`sidebar:${ sidebarKey }:${ side }:width` sidebarKey: string,
side: SidebarSide,
verticalPreview = false,
): string =>
`sidebar:${ sidebarKey }:${ side }:${ verticalPreview ? 'height' : 'width' }`
const getSidebarMaxWidth = (containerWidth: number): number => { const getSidebarMaxWidth = (containerSize: number): number => {
const viewportWidth = const viewportWidth =
typeof window === 'undefined' ? containerWidth : window.innerWidth typeof window === 'undefined' ? containerSize : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO) const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const containerMaxWidth = Math.floor (containerWidth - MIN_MAIN_WIDTH) const containerMaxWidth = Math.floor (containerSize - MIN_MAIN_WIDTH)
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth) const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth) return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
} }
const readStoredSidebarWidth = ( const readStoredSidebarSize = (
sidebarKey: string, sidebarKey: string,
side: SidebarSide): number => { side: SidebarSide,
verticalPreview: boolean,
): number => {
if (typeof window === 'undefined') if (typeof window === 'undefined')
return DEFAULT_SIDEBAR_WIDTH return DEFAULT_SIDEBAR_WIDTH
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side)) try
const width = value == null ? Number.NaN : Number (value) {
const value = window.localStorage.getItem (
return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH storageKeyForSidebar (sidebarKey, side, verticalPreview))
const size = value == null ? Number.NaN : Number (value)
return Number.isFinite (size) ? size : DEFAULT_SIDEBAR_WIDTH
}
catch
{
return DEFAULT_SIDEBAR_WIDTH
}
} }
const SidebarComponent: FC<Props> = ({ const SidebarComponent: FC<Props> = (
children, { children, className, maxWidth, maxWidthRatio, animateLayout = true,
className, onWidthChange, sidebarKey, side = 'left' },
maxWidth, ) => {
maxWidthRatio,
animateLayout = true,
onWidthChange,
sidebarKey,
side = 'left',
}) => {
const { message } = useUITranslation ('shared') const { message } = useUITranslation ('shared')
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const verticalPreview = useVerticalPreview ()
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition ( const layoutTransition = clientAnimationTransition (
animationMode, animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } }, { normal: { duration: .2, ease: 'easeOut' as const } })
)
const rootRef = useRef<HTMLDivElement | null> (null) const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth) const maxWidthRef = useRef (maxWidth)
const maxWidthRatioRef = useRef (maxWidthRatio) const maxWidthRatioRef = useRef (maxWidthRatio)
const getContainerWidth = useCallback (() => { const getContainerSize = useCallback (() => {
const containerWidth = rootRef.current const container = rootRef.current?.closest ('[data-sidebar-container]')
?.closest ('[data-sidebar-container]') return verticalPreview
?.clientWidth ? container?.clientHeight ?? window.innerHeight
?? window.innerWidth : container?.clientWidth ?? window.innerWidth
return containerWidth }, [verticalPreview])
}, []) const getClampedSize = useCallback ((
const getClampedWidth = useCallback (( nextSize: number,
nextWidth: number, containerSize: number,
containerWidth: number): number => { ): number => {
if (verticalPreview)
{
const maximum = Math.max (64, Math.min (
window.innerHeight * .6, containerSize - 160))
return Math.min (maximum, Math.max (64, nextSize))
}
const viewportWidth = const viewportWidth =
typeof window === 'undefined' ? containerWidth : window.innerWidth typeof window === 'undefined' ? containerSize : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO) const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const ratioMaxWidth = maxWidthRatioRef.current == null const ratioMaxWidth = maxWidthRatioRef.current == null
? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current) ? Infinity : Math.floor (containerSize * maxWidthRatioRef.current)
const configuredMaxWidth = maxWidthRef.current == null const configuredMaxWidth = maxWidthRef.current == null
? getSidebarMaxWidth (containerWidth) ? getSidebarMaxWidth (containerSize)
: Math.max (MIN_SIDEBAR_WIDTH, : Math.max (MIN_SIDEBAR_WIDTH,
Math.min (viewportMaxWidth, maxWidthRef.current)) Math.min (viewportMaxWidth, maxWidthRef.current))
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH, const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
Math.min (ratioMaxWidth, configuredMaxWidth)) Math.min (ratioMaxWidth, configuredMaxWidth))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth)) return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextSize))
}, []) }, [verticalPreview])
const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH) const [size, setSize] = useState (DEFAULT_SIDEBAR_WIDTH)
const [resizing, setResizing] = useState (false) const [resizing, setResizing] = useState (false)
const widthRef = useRef (width) const sizeRef = useRef (size)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null) const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const pendingPressRef = useRef<PendingPressState | null> (null) const pendingPressRef = useRef<ResizeState | null> (null)
const resizeRef = useRef<ResizeState | null> (null) const resizeRef = useRef<ResizeState | null> (null)
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null) const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
@@ -148,7 +162,7 @@ const SidebarComponent: FC<Props> = ({
userSelect: document.body.style.userSelect } userSelect: document.body.style.userSelect }
} }
document.body.style.cursor = 'col-resize' document.body.style.cursor = state.verticalPreview ? 'row-resize' : 'col-resize'
document.body.style.userSelect = 'none' document.body.style.userSelect = 'none'
}, [clearLongPressTimer]) }, [clearLongPressTimer])
@@ -159,43 +173,50 @@ const SidebarComponent: FC<Props> = ({
const state = resizeRef.current const state = resizeRef.current
resizeRef.current = null resizeRef.current = null
if (state != null) try
window.localStorage.setItem ( {
storageKeyForSidebar (sidebarKey, state.side), if (state != null)
String (widthRef.current)) window.localStorage.setItem (
storageKeyForSidebar (sidebarKey, state.side, state.verticalPreview),
String (sizeRef.current))
}
catch
{
// Resizing remains usable when browser storage is unavailable.
}
setResizing (false) setResizing (false)
restoreBodyStyle () restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey]) }, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
const applyWidth = useCallback ((nextWidth: number) => { const applySize = useCallback ((nextSize: number) => {
if (nextWidth === widthRef.current) if (nextSize === sizeRef.current)
return return
widthRef.current = nextWidth sizeRef.current = nextSize
setWidth (nextWidth) setSize (nextSize)
}, []) }, [])
useEffect (() => { useEffect (() => {
maxWidthRef.current = maxWidth maxWidthRef.current = maxWidth
maxWidthRatioRef.current = maxWidthRatio maxWidthRatioRef.current = maxWidthRatio
const containerWidth = getContainerWidth () const containerSize = getContainerSize ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth) const nextSize = getClampedSize (sizeRef.current, containerSize)
applyWidth (nextWidth) applySize (nextSize)
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio]) }, [applySize, getClampedSize, getContainerSize, maxWidth, maxWidthRatio])
const updateWidth = useCallback ((clientX: number) => { const updateSize = useCallback ((clientX: number, clientY: number) => {
const state = resizeRef.current const state = resizeRef.current
if (state == null) if (state == null)
return return
const containerWidth = getContainerWidth () const containerSize = getContainerSize ()
const delta = state.side === 'left' const delta = state.verticalPreview
? clientX - state.startX ? clientY - state.startY
: state.startX - clientX : (state.side === 'left' ? clientX - state.startX : state.startX - clientX)
const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth) const nextSize = getClampedSize (state.startSize + delta, containerSize)
applyWidth (nextWidth) applySize (nextSize)
}, [applyWidth, getClampedWidth, getContainerWidth]) }, [applySize, getClampedSize, getContainerSize])
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => { const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch') if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
@@ -211,8 +232,10 @@ const SidebarComponent: FC<Props> = ({
const state = { const state = {
pointerId: ev.pointerId, pointerId: ev.pointerId,
side, side,
startWidth: widthRef.current, startSize: sizeRef.current,
startX: ev.clientX } startX: ev.clientX,
startY: ev.clientY,
verticalPreview }
if (ev.pointerType === 'mouse') if (ev.pointerType === 'mouse')
{ {
@@ -220,7 +243,7 @@ const SidebarComponent: FC<Props> = ({
return return
} }
pendingPressRef.current = { ...state, startY: ev.clientY } pendingPressRef.current = state
clearLongPressTimer () clearLongPressTimer ()
longPressTimerRef.current = setTimeout (() => { longPressTimerRef.current = setTimeout (() => {
if (pendingPressRef.current != null) if (pendingPressRef.current != null)
@@ -234,7 +257,7 @@ const SidebarComponent: FC<Props> = ({
if (resizeRef.current != null) if (resizeRef.current != null)
{ {
ev.preventDefault () ev.preventDefault ()
updateWidth (ev.clientX) updateSize (ev.clientX, ev.clientY)
return return
} }
@@ -262,58 +285,58 @@ const SidebarComponent: FC<Props> = ({
finishResize () finishResize ()
} }
const resetWidth = useCallback (() => { const resetSize = useCallback (() => {
const containerWidth = getContainerWidth () const containerSize = getContainerSize ()
const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth) const nextSize = getClampedSize (DEFAULT_SIDEBAR_WIDTH, containerSize)
applyWidth (nextWidth) applySize (nextSize)
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side)) window.localStorage.removeItem (
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side]) storageKeyForSidebar (sidebarKey, side, verticalPreview))
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => { const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
ev.preventDefault () ev.preventDefault ()
ev.stopPropagation () ev.stopPropagation ()
resetWidth () resetSize ()
} }
useEffect (() => { useEffect (() => {
const containerWidth = getContainerWidth () const containerSize = getContainerSize ()
const nextWidth = getClampedWidth ( const nextSize = getClampedSize (
readStoredSidebarWidth (sidebarKey, side), readStoredSidebarSize (sidebarKey, side, verticalPreview),
containerWidth) containerSize)
applyWidth (nextWidth) applySize (nextSize)
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side]) }, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
useEffect (() => { useEffect (() => {
const containerWidth = getContainerWidth () const containerSize = getContainerSize ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth) const nextSize = getClampedSize (sizeRef.current, containerSize)
applyWidth (nextWidth) applySize (nextSize)
}, [applyWidth, getClampedWidth, getContainerWidth]) }, [applySize, getClampedSize, getContainerSize])
useEffect (() => { useEffect (() => {
if (typeof window === 'undefined') if (typeof window === 'undefined')
return return
const handleResize = () => { const handleResize = () => {
const containerWidth = getContainerWidth () const containerSize = getContainerSize ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth) const nextSize = getClampedSize (sizeRef.current, containerSize)
applyWidth (nextWidth) applySize (nextSize)
} }
window.addEventListener ('resize', handleResize) window.addEventListener ('resize', handleResize)
return () => window.removeEventListener ('resize', handleResize) return () => window.removeEventListener ('resize', handleResize)
}, [applyWidth, getClampedWidth, getContainerWidth]) }, [applySize, getClampedSize, getContainerSize])
useEffect (() => { useEffect (() => {
onWidthChange?.(width) if (!verticalPreview)
}, [onWidthChange, width]) onWidthChange?.(size)
}, [onWidthChange, size, verticalPreview])
useEffect (() => () => { useEffect (() => () => finishResize (), [finishResize, verticalPreview])
clearLongPressTimer ()
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle])
const style = { const style = {
'--sidebar-width': `${ width }px` } as CSSProperties '--sidebar-width': `${ size }px`,
'--sidebar-height': `${ size }px` } as CSSProperties
return ( return (
<motion.div <motion.div
@@ -322,7 +345,7 @@ const SidebarComponent: FC<Props> = ({
transition={{ layout: layoutTransition }} transition={{ layout: layoutTransition }}
style={style} style={style}
className={cn ( className={cn (
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full', 'sidebar-panel relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
className)}> className)}>
<Helmet> <Helmet>
<style> <style>
@@ -345,16 +368,16 @@ const SidebarComponent: FC<Props> = ({
<div <div
role="separator" role="separator"
aria-orientation="vertical" aria-orientation={verticalPreview ? 'horizontal' : 'vertical'}
aria-label={message ('sidebarWidth').text} aria-label={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').text}
lang={message ('sidebarWidth').language} lang={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').language}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd} onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd} onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
className={cn ( className={cn (
'hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize', 'sidebar-resize hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
'touch-none select-none', 'touch-none select-none',
side === 'left' ? '-right-2' : '-left-2', side === 'left' ? '-right-2' : '-left-2',
'before:absolute before:top-0 before:bottom-0 before:w-px', 'before:absolute before:top-0 before:bottom-0 before:w-px',
+7 -3
ファイルの表示
@@ -9,7 +9,8 @@ import { createStaticHandler, createStaticRouter,
import { AppFrame, appRoutes, resolvePageRoute } from '@/App' import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
import { apiGet, isApiError } from '@/lib/api' import { apiGet, isApiError } from '@/lib/api'
import { createUII18n } from '@/lib/i18n' import { createUII18n } from '@/lib/i18n'
import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales' import { fetchLocales, isLocaleCode, isVerticalPreviewPath,
legacyLocalePath } from '@/lib/locales'
import { localesKeys, sessionKeys } from '@/lib/queryKeys' import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { withRenderContext } from '@/lib/renderContext.server' import { withRenderContext } from '@/lib/renderContext.server'
@@ -72,13 +73,15 @@ export const render = async (
pathname => resolvePageRoute (pathname) != null) pathname => resolvePageRoute (pathname) != null)
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
const segment = pathname.split ('/')[1] const segment = pathname.split ('/')[1]
const verticalPreview = isVerticalPreviewPath (pathname)
const known = resolvePageRoute (pathname) const known = resolvePageRoute (pathname)
if (legacy != null && known != null) if (legacy != null && known != null)
return { status: 308, location: legacy } return { status: 308, location: legacy }
const locale = isLocaleCode (segment) ? segment : 'ja' const locale = isLocaleCode (segment) ? segment : 'ja'
const routePrefix = verticalPreview ? 'mn-Mong' : locale
if (known?.route.handle?.redirectTo != null) if (known?.route.handle?.redirectTo != null)
return { status: 308, return { status: 308,
location: `/${locale}${known.route.handle.redirectTo}${url.search}` } location: `/${routePrefix}${known.route.handle.redirectTo}${url.search}` }
const requestContext: RenderContext & { status: number } = { const requestContext: RenderContext & { status: number } = {
pathname: url.pathname, origin, apiBaseURL, pathname: url.pathname, origin, apiBaseURL,
...authentication, status: 200 } ...authentication, status: 200 }
@@ -106,7 +109,8 @@ export const render = async (
} }
} }
const prepare = known?.route.handle?.prepare const prepare = known?.route.handle?.prepare
let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200 const routeLocaleValid = isLocaleCode (segment) || verticalPreview
let status = known == null || !active || !routeLocaleValid ? 404 : 200
if (status === 200 && known?.route.handle?.authentication != null if (status === 200 && known?.route.handle?.authentication != null
&& client.getQueryData (sessionKeys.current) == null) && client.getQueryData (sessionKeys.current) == null)
status = 401 status = 401
+500
ファイルの表示
@@ -625,3 +625,503 @@ body
white-space: nowrap; white-space: nowrap;
will-change: transform; will-change: transform;
} }
/* Loaded only by the development-only sample, with the OFL alongside the font. */
@font-face
{
font-family: 'BTRC Mongolian Preview';
src: url('/fonts/noto-sans-mongolian/NotoSansMongolian-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Development-only /mn-Mong layout. Unannotated pages retain horizontal text. */
[data-vertical-preview]
{
min-width: 0;
min-height: 0;
overflow: hidden;
}
[data-vertical-preview] > :not(.top-nav-layout)
{
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
}
[data-vertical-preview] [data-sidebar-container]
{
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
[data-vertical-preview] .main-area
{
min-width: 0;
min-height: 0;
overscroll-behavior: contain;
}
[data-vertical-preview] .main-area[data-vertical-content]
{
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
}
[data-vertical-preview] .sidebar-panel
{
width: 100%;
height: var(--sidebar-height);
min-height: 0;
flex: none;
}
[data-vertical-preview] .sidebar
{
display: flex;
align-items: flex-start;
gap: 1rem;
direction: ltr;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior: contain;
}
[data-vertical-preview] .sidebar > *
{
flex-shrink: 0;
}
[data-vertical-preview] .sidebar > form
{
width: 16rem;
height: 100%;
overflow-y: auto;
}
[data-vertical-preview] .sidebar > form > ul
{
position: relative;
}
[data-vertical-preview] .sidebar > div
{
writing-mode: vertical-lr;
text-orientation: mixed;
height: 100%;
margin: 0;
}
[data-vertical-preview] .sidebar :is(h2, h3, ul)
{
margin: 0;
margin-block-end: 1rem;
}
[data-vertical-preview] .sidebar-resize
{
display: block;
inset: auto 0 -.5rem;
width: 100%;
height: 1rem;
cursor: row-resize;
}
[data-vertical-preview] .sidebar-resize::before
{
inset: 50% 0 auto;
width: 100%;
height: 1px;
}
[data-vertical-preview] .vertical-preview-sample
{
writing-mode: vertical-lr;
text-orientation: mixed;
font-family: 'BTRC Mongolian Preview', 'Mongolian Baiti', sans-serif;
font-size: 1.25rem;
line-height: 1.5;
flex-shrink: 0;
}
/* The post grid fills each column downwards before continuing to the right. */
[data-vertical-preview] .posts-main-area > div
{
display: flex;
height: 100%;
width: max-content;
margin: 0;
gap: 1rem;
}
[data-vertical-preview] .posts-main-area > div > div:first-child
{
flex-direction: column;
justify-content: flex-start;
writing-mode: vertical-lr;
text-orientation: mixed;
}
[data-vertical-preview] .posts-main-area > div > div:last-child
{
display: flex;
width: max-content;
height: 100%;
margin: 0;
gap: 1rem;
}
[data-vertical-preview] .main-area[data-vertical-content] .post-list
{
display: grid;
grid-auto-flow: column;
grid-auto-columns: 10rem;
grid-template-rows: repeat(auto-fill, min(10rem, 100%));
flex: none;
width: max-content;
height: 100%;
writing-mode: horizontal-tb;
}
[data-vertical-preview] .main-area[data-vertical-content] .post-list > a
{
height: 100%;
}
[data-vertical-preview] .main-area[data-vertical-content] nav
{
flex: none;
align-self: flex-start;
margin: 0;
writing-mode: horizontal-tb;
}
[data-vertical-preview] .tags-main-area
{
display: flex;
align-items: stretch;
gap: 1.5rem;
}
[data-vertical-preview] .tag-list-filter
{
flex: none;
width: min(24rem, 75vw);
max-width: none;
overflow-y: auto;
}
[data-vertical-preview] .tag-list-filter h1
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: 8rem;
margin-block: 0;
}
[data-vertical-preview] .tag-list-filter input[type='number']
{
max-width: 7rem;
}
[data-vertical-preview] .tag-list-results
{
display: flex;
flex: none;
gap: 1rem;
max-width: none;
margin: 0;
}
[data-vertical-preview] .tag-list-table
{
max-width: none;
overflow-x: visible;
overflow-y: auto;
}
[data-vertical-preview] .tag-list-table table
{
writing-mode: vertical-lr;
text-orientation: mixed;
inline-size: max-content;
block-size: max-content;
min-width: 0;
}
[data-vertical-preview] .tag-list-table col
{
width: auto;
}
[data-vertical-preview] .tag-list-table :is(th, td)
{
min-inline-size: 2rem;
max-inline-size: 16rem;
text-align: start;
}
[data-vertical-preview] .tag-list-table thead
{
border-bottom: 0;
border-block-end: 2px solid currentColor;
}
[data-vertical-preview] .tag-list-table td:nth-child(2),
[data-vertical-preview] .tag-list-table time
{
writing-mode: horizontal-tb;
white-space: nowrap;
}
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee,
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee__static
{
white-space: normal;
overflow: visible;
text-overflow: clip;
max-inline-size: 16rem;
opacity: 1;
}
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee__animated
{
display: none;
}
/* Prose flows through the viewport height and grows into columns on the right. */
[data-vertical-preview] .wiki-main-area
{
display: flex;
gap: 1rem;
}
[data-vertical-preview] .wiki-main-area > div
{
flex: none;
writing-mode: vertical-lr;
text-orientation: mixed;
margin: 0;
}
[data-vertical-preview] .wiki-main-area > div > span
{
writing-mode: horizontal-tb;
}
[data-vertical-preview] .wiki-main-area article,
[data-vertical-preview] .posts-main-area .localised-document
{
writing-mode: vertical-lr;
text-orientation: mixed;
inline-size: 100%;
block-size: max-content;
max-width: none;
margin: 0;
flex: none;
}
[data-vertical-preview] .main-area[data-vertical-content] .localised-document
{
max-width: none;
overflow: visible;
}
[data-vertical-preview] .wiki-main-area article :is(h1, h2, h3, h4, p, ul, ol, blockquote)
{
margin: 0;
margin-block: 1em;
}
[data-vertical-preview] .wiki-main-area article :is(ul, ol)
{
padding: 0;
padding-inline-start: 1.5em;
}
[data-vertical-preview] .wiki-main-area article :is(img, video, iframe, svg)
{
writing-mode: horizontal-tb;
max-height: 100%;
max-width: none;
object-fit: contain;
}
[data-vertical-preview] .wiki-main-area article :is(pre, table, .not-prose),
[data-vertical-preview] .main-area[data-vertical-content] :is(code, time, input, select, textarea)
{
writing-mode: horizontal-tb;
text-orientation: mixed;
}
[data-vertical-preview] .wiki-main-area article :is(pre, table, .not-prose)
{
display: inline-block;
max-height: 100%;
max-width: min(48rem, 80vw);
overflow: auto;
vertical-align: top;
}
[data-vertical-preview] .wiki-main-area article .not-prose
{
width: max-content;
height: 100%;
}
[data-vertical-preview] .wiki-main-area .not-prose > div
{
display: flex;
flex-direction: column;
height: 100%;
margin: 0;
}
[data-vertical-preview] .wiki-main-area .not-prose > div > div:last-child
{
flex: 1;
min-height: 0;
}
@media (min-width: 768px)
{
[data-vertical-preview].app-layout
{
flex-direction: row;
}
[data-vertical-preview] .top-nav-layout
{
display: flex;
width: max-content;
max-width: 50vw;
height: 100%;
}
[data-vertical-preview] .top-nav-bar
{
flex-direction: column;
flex: none;
width: 7rem;
height: 100%;
padding: .5rem;
gap: .5rem;
}
[data-vertical-preview] .top-nav-primary
{
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
height: auto;
overflow-y: auto;
}
[data-vertical-preview] .top-nav-brand-link
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: 9rem;
white-space: normal;
margin-block: .5rem;
}
[data-vertical-preview] .top-nav-desktop-menu
{
flex-direction: column;
width: 100%;
height: auto;
}
[data-vertical-preview] .top-nav-desktop-menu > div:not(.top-nav-highlight)
{
width: 100%;
}
[data-vertical-preview] .top-nav-desktop-menu .top-nav-menu-link
{
justify-content: center;
width: 100%;
min-height: 3rem;
height: auto;
padding: .5rem;
}
[data-vertical-preview] .top-nav-menu-link > :not(svg, select),
[data-vertical-preview] .top-nav-more-menu h2
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: 9rem;
white-space: normal;
}
[data-vertical-preview] .top-nav-highlight
{
inset-inline-start: 0;
}
[data-vertical-preview] .top-nav-account
{
flex-direction: column;
width: 100%;
margin: 0;
}
[data-vertical-preview] .top-nav-account .top-nav-user-desktop
{
max-width: 100%;
margin: 0;
padding: 0;
}
[data-vertical-preview] .top-nav-locale-name
{
display: inline;
writing-mode: horizontal-tb;
}
[data-vertical-preview] .top-nav-desktop-submenu
{
min-width: 0;
height: 100%;
}
[data-vertical-preview] .top-nav-submenu-content
{
width: max-content;
max-width: 35vw;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
[data-vertical-preview] :is(.top-nav-submenu-items, .top-nav-more-links)
{
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
}
[data-vertical-preview] .top-nav-submenu-items .top-nav-menu-link
{
justify-content: center;
}
}
@media (max-width: 767px)
{
[data-vertical-preview] .top-nav-layout
{
max-height: 50dvh;
overflow-y: auto;
}
}
+13 -5
ファイルの表示
@@ -28,6 +28,11 @@ export const localeFromPath = (pathname: string): LocaleCode | null => {
} }
// A development-only layout route; API and dictionary locales stay unchanged.
export const isVerticalPreviewPath = (pathname: string): boolean =>
import.meta.env.DEV && /^\/mn-Mong(?:[/?#]|$)/.test (pathname)
export const resolveViewingLocale = ( export const resolveViewingLocale = (
pathname: string, pathname: string,
routeLocale?: unknown, routeLocale?: unknown,
@@ -40,20 +45,22 @@ export const currentViewingLocale = (): LocaleCode =>
export const unlocalisedPath = (pathname: string): string => { export const unlocalisedPath = (pathname: string): string => {
const locale = localeFromPath (pathname) const locale = isVerticalPreviewPath (pathname) ? 'mn-Mong' : localeFromPath (pathname)
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/' return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
} }
export const localiseTo = (to: To, locale: LocaleCode): To => { export const localiseTo = (to: To, locale: LocaleCode, viewingPath = ''): To => {
const pathname = typeof to === 'string' ? to : to.pathname const pathname = typeof to === 'string' ? to : to.pathname
if (pathname == null if (pathname == null
|| !(pathname.startsWith ('/')) || !(pathname.startsWith ('/'))
|| pathname.startsWith ('//') || pathname.startsWith ('//')
|| localeFromPath (pathname) != null) || localeFromPath (pathname) != null
|| isVerticalPreviewPath (pathname))
return to return to
const localised = `/${ locale }${ pathname }` const prefix = isVerticalPreviewPath (viewingPath) ? 'mn-Mong' : locale
const localised = `/${ prefix }${ pathname }`
return typeof to === 'string' ? localised : { ...to, pathname: localised } return typeof to === 'string' ? localised : { ...to, pathname: localised }
} }
@@ -73,7 +80,8 @@ export const legacyLocalePath = (
location: { pathname: string; search: string; hash: string }, location: { pathname: string; search: string; hash: string },
isPage: (pathname: string) => boolean = () => false, isPage: (pathname: string) => boolean = () => false,
): string | null => { ): string | null => {
if (localeFromPath (location.pathname) != null || !isPage (location.pathname)) if (localeFromPath (location.pathname) != null
|| isVerticalPreviewPath (location.pathname) || !isPage (location.pathname))
return null return null
return switchLocalePath (location, automaticallySelectedLocale ()) return switchLocalePath (location, automaticallySelectedLocale ())
} }
+8 -3
ファイルの表示
@@ -1,7 +1,7 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { localiseTo, resolveViewingLocale } from '@/lib/locales' import { isVerticalPreviewPath, localiseTo, resolveViewingLocale } from '@/lib/locales'
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom' import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
@@ -18,10 +18,15 @@ export const useLocale = (): LocaleCode => {
export const useLocaleNavigate = (): NavigateFunction => { export const useLocaleNavigate = (): NavigateFunction => {
const navigate = useNavigate () const navigate = useNavigate ()
const locale = useLocale () const locale = useLocale ()
const { pathname } = useLocation ()
return useCallback ((to: To | number, options?: NavigateOptions) => { return useCallback ((to: To | number, options?: NavigateOptions) => {
if (typeof to === 'number') if (typeof to === 'number')
navigate (to) navigate (to)
else else
navigate (localiseTo (to, locale), options) navigate (localiseTo (to, locale, pathname), options)
}, [locale, navigate]) }, [locale, navigate, pathname])
} }
export const useVerticalPreview = (): boolean =>
isVerticalPreviewPath (useLocation ().pathname)
+1 -1
ファイルの表示
@@ -146,7 +146,7 @@ const PostListPage: FC = () => {
JSON.stringify (statesToSave)) JSON.stringify (statesToSave))
}}/> }}/>
<MainArea> <MainArea className="posts-main-area" verticalContent>
<TabGroup <TabGroup
rightAddon={ rightAddon={
<label className="flex items-center gap-2 text-xs text-muted-foreground"> <label className="flex items-center gap-2 text-xs text-muted-foreground">
+4 -4
ファイルの表示
@@ -220,10 +220,10 @@ const TagListPage: FC = () => {
}), [page, totalPages, location.search])) }), [page, totalPages, location.search]))
return ( return (
<MainArea className="min-w-0"> <MainArea className="tags-main-area min-w-0" verticalContent>
<PageHead title={message ('title')}/> <PageHead title={message ('title')}/>
<div className="max-w-xl"> <div className="tag-list-filter max-w-xl">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle>{label ('title')}</PageTitle> <PageTitle>{label ('title')}</PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground"> <label className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -358,8 +358,8 @@ const TagListPage: FC = () => {
{isError && <p role="alert">{label ('common:fetchError')}</p>} {isError && <p role="alert">{label ('common:fetchError')}</p>}
{loading ? label ('common:loading') : (results.length > 0 ? ( {loading ? label ('common:loading') : (results.length > 0 ? (
<div className="mt-4 min-w-0 max-w-full"> <div className="tag-list-results mt-4 min-w-0 max-w-full">
<div className="min-w-0 max-w-full overflow-x-auto"> <div className="tag-list-table min-w-0 max-w-full overflow-x-auto">
<table className="w-full min-w-[2000px] table-auto border-collapse"> <table className="w-full min-w-[2000px] table-auto border-collapse">
<colgroup> <colgroup>
<col className="w-72"/> <col className="w-72"/>
+1 -1
ファイルの表示
@@ -114,7 +114,7 @@ const WikiDetailPage: FC = () => {
return <NotFound/> return <NotFound/>
return ( return (
<MainArea> <MainArea className="wiki-main-area" verticalContent>
<PageHead title={{ <PageHead title={{
text: `${displayTitle}${deprecated text: `${displayTitle}${deprecated
? message ('labels.deprecatedSuffix').text : ''} Wiki`, ? message ('labels.deprecatedSuffix').text : ''} Wiki`,