コミットを比較
4
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
2b7f52978f | ||
|
|
dbae864f24 | ||
|
|
8d00a6545e | ||
|
|
f8747a0f74 |
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
バイナリファイルは表示されません。
@@ -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/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする