このコミットが含まれているのは:
@@ -10,6 +10,7 @@ class ApplicationController < ActionController::API
|
|||||||
before_action :reject_banned_ip_address!
|
before_action :reject_banned_ip_address!
|
||||||
before_action :authenticate_user
|
before_action :authenticate_user
|
||||||
before_action :reject_banned_user!
|
before_action :reject_banned_user!
|
||||||
|
before_action :validate_explicit_locale!
|
||||||
|
|
||||||
def current_user = @current_user
|
def current_user = @current_user
|
||||||
|
|
||||||
@@ -99,6 +100,8 @@ class ApplicationController < ActionController::API
|
|||||||
end
|
end
|
||||||
|
|
||||||
def resolve_locale!
|
def resolve_locale!
|
||||||
|
return @resolved_locale if defined?(@resolved_locale)
|
||||||
|
|
||||||
code = params.fetch(:locale, 'ja')
|
code = params.fetch(:locale, 'ja')
|
||||||
unless code.is_a?(String) && Locale.codes.value?(code)
|
unless code.is_a?(String) && Locale.codes.value?(code)
|
||||||
raise UnsupportedLocale
|
raise UnsupportedLocale
|
||||||
@@ -107,6 +110,10 @@ class ApplicationController < ActionController::API
|
|||||||
locale = Locale.find_by(code:)
|
locale = Locale.find_by(code:)
|
||||||
raise UnsupportedLocale unless locale && locale.deprecated_at.nil?
|
raise UnsupportedLocale unless locale && locale.deprecated_at.nil?
|
||||||
|
|
||||||
locale
|
@resolved_locale = locale
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_explicit_locale!
|
||||||
|
resolve_locale! if params.key?(:locale)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
class LocalesController < ApplicationController
|
||||||
|
def index
|
||||||
|
locales = Locale.where(deprecated_at: nil).order(:created_at).map do |locale|
|
||||||
|
{ code: locale.code_before_type_cast, name: locale.name }
|
||||||
|
end
|
||||||
|
|
||||||
|
response.headers['Cache-Control'] = 'no-store'
|
||||||
|
render json: { locales: }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -14,7 +14,5 @@ class Locale < ApplicationRecord
|
|||||||
deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?)
|
deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?)
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_tag_names!
|
def generate_tag_names! = TagNameCompletion.for_locale!(self)
|
||||||
TagNameCompletion.for_locale!(self)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -110,13 +110,8 @@ class Tag < ApplicationRecord
|
|||||||
|
|
||||||
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
|
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
|
||||||
|
|
||||||
def snapshot_aliases(language_code)
|
def snapshot_aliases language_code
|
||||||
if tag_names.loaded?
|
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
|
||||||
tag_names.select { _1.language_code == language_code && !(_1.primary?) }
|
|
||||||
.sort_by(&:name).map(&:name)
|
|
||||||
else
|
|
||||||
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
|
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
|
||||||
|
|||||||
@@ -1,43 +1,47 @@
|
|||||||
class TagNameCompletion
|
class TagNameCompletion
|
||||||
def self.for_locale! locale
|
class << self
|
||||||
named_tags = TagName.where(language_code: locale.language_code, primary_flg: true)
|
def for_locale! locale
|
||||||
.select(:tag_id)
|
named_tags =
|
||||||
Tag.where.not(id: named_tags).find_each do |tag|
|
TagName.where(language_code: locale.language_code, primary_flg: true)
|
||||||
for_tag!(tag, locales: [locale])
|
.select(:tag_id)
|
||||||
end
|
Tag.where.not(id: named_tags).find_each do |tag|
|
||||||
end
|
for_tag!(tag, locales: [locale])
|
||||||
|
end
|
||||||
def self.for_tag! tag, locales: nil
|
end
|
||||||
locales ||= Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code)
|
|
||||||
tag.with_lock do
|
def for_tag! tag, locales: nil
|
||||||
primary_names = tag.tag_names.where(primary_flg: true).index_by(&:language_code)
|
locales ||= Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code)
|
||||||
locales.to_a.uniq(&:language_code).each do |locale|
|
tag.with_lock do
|
||||||
next if primary_names.key?(locale.language_code)
|
primary_names = tag.tag_names.where(primary_flg: true).index_by(&:language_code)
|
||||||
|
locales.to_a.uniq(&:language_code).each do |locale|
|
||||||
name = TagName.generate_name(locale, tag, primary_names['ja'])
|
next if primary_names.key?(locale.language_code)
|
||||||
primary_names[locale.language_code] = create_name!(locale, tag, name)
|
|
||||||
|
name = TagName.generate_name(locale, tag, primary_names['ja'])
|
||||||
|
primary_names[locale.language_code] = create_name!(locale, tag, name)
|
||||||
|
end
|
||||||
|
tag.association(:tag_names).reset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_name! locale, tag, name
|
||||||
|
persist_name!(locale, tag, name)
|
||||||
|
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
|
||||||
|
fallback = "Tag_##{ tag.id }"
|
||||||
|
collision =
|
||||||
|
error.is_a?(ActiveRecord::RecordNotUnique) ||
|
||||||
|
error.record.errors.of_kind?(:name, :taken)
|
||||||
|
raise if name == fallback || !(collision)
|
||||||
|
|
||||||
|
persist_name!(locale, tag, fallback)
|
||||||
|
end
|
||||||
|
|
||||||
|
def persist_name! locale, tag, name
|
||||||
|
TagName.transaction(requires_new: true) do
|
||||||
|
TagName.create!(tag:, name:, language_code: locale.language_code,
|
||||||
|
script_code: locale.script_code, primary_flg: true, auto_generated: true)
|
||||||
end
|
end
|
||||||
tag.association(:tag_names).reset
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.create_name! locale, tag, name
|
|
||||||
persist_name!(locale, tag, name)
|
|
||||||
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
|
|
||||||
fallback = "Tag_##{ tag.id }"
|
|
||||||
collision = error.is_a?(ActiveRecord::RecordNotUnique) ||
|
|
||||||
error.record.errors.of_kind?(:name, :taken)
|
|
||||||
raise unless name != fallback && collision
|
|
||||||
|
|
||||||
persist_name!(locale, tag, fallback)
|
|
||||||
end
|
|
||||||
private_class_method :create_name!
|
|
||||||
|
|
||||||
def self.persist_name! locale, tag, name
|
|
||||||
TagName.transaction(requires_new: true) do
|
|
||||||
TagName.create!(tag:, name:, language_code: locale.language_code,
|
|
||||||
script_code: locale.script_code, primary_flg: true, auto_generated: true)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
private_class_method :persist_name!
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,19 +5,52 @@ module TagNameTranscription
|
|||||||
|
|
||||||
def self.ainu name
|
def self.ainu name
|
||||||
previous_script = nil
|
previous_script = nil
|
||||||
JapaneseSegments.split(name).map { |script, text|
|
parts = []
|
||||||
boundary = previous_script && script != previous_script &&
|
NipponeseSegments.split(name).each do |script, text|
|
||||||
JapaneseSegments.japanese?(previous_script) && JapaneseSegments.japanese?(script)
|
output = transcribe_segment(script, text)
|
||||||
previous_script = script
|
if (parts.last && output.first && previous_script && script != previous_script &&
|
||||||
reading = script == :kanji ? JisReadings.read(text) : text
|
NipponeseSegments.nipponese?(previous_script) && NipponeseSegments.nipponese?(script))
|
||||||
output =
|
parts << '_'
|
||||||
if script == :literal
|
end
|
||||||
text
|
|
||||||
else
|
|
||||||
Katakana.render(Phonology.adapt(reading))
|
|
||||||
end
|
|
||||||
|
|
||||||
boundary ? "_#{ output }" : output
|
parts.concat(output)
|
||||||
|
previous_script = script
|
||||||
|
end
|
||||||
|
|
||||||
|
replace_unconvertible(parts)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.transcribe_segment script, text
|
||||||
|
return [text] if script == :literal
|
||||||
|
return [nil] if script == :unconvertible
|
||||||
|
return transcribe_reading(text) unless script == :kanzi
|
||||||
|
|
||||||
|
text.each_char.flat_map do |character|
|
||||||
|
transcribe_reading(JisReadings.read(character))
|
||||||
|
rescue Unconvertible
|
||||||
|
[nil]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.transcribe_reading reading
|
||||||
|
Phonology.adapt(reading).map do |sound|
|
||||||
|
sound && Katakana.render([sound])
|
||||||
|
rescue Unconvertible
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.replace_unconvertible parts
|
||||||
|
grouped = parts.chunk { _1.nil? }.flat_map { |unconvertible, group|
|
||||||
|
unconvertible ? [nil] : group
|
||||||
|
}
|
||||||
|
first = grouped.index { _1&.match?(/[^_]/) }
|
||||||
|
last = grouped.rindex { _1&.match?(/[^_]/) }
|
||||||
|
|
||||||
|
grouped.each_with_index.map { |part, index|
|
||||||
|
part || (first && first < index && index < last ? '_' : '')
|
||||||
}.join
|
}.join
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private_class_method :transcribe_segment, :transcribe_reading, :replace_unconvertible
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ module TagNameTranscription
|
|||||||
'k' => 'ㇰ', 's' => 'ㇱ', 't' => 'ッ', 'n' => 'ン', 'm' => 'ㇺ', 'p' => 'ㇷ゚',
|
'k' => 'ㇰ', 's' => 'ㇱ', 't' => 'ッ', 'n' => 'ン', 'm' => 'ㇺ', 'p' => 'ㇷ゚',
|
||||||
'r:a' => 'ㇻ', 'r:i' => 'ㇼ', 'r:u' => 'ㇽ', 'r:e' => 'ㇾ', 'r:o' => 'ㇿ').freeze
|
'r:a' => 'ㇻ', 'r:i' => 'ㇼ', 'r:u' => 'ㇽ', 'r:e' => 'ㇾ', 'r:o' => 'ㇿ').freeze
|
||||||
|
|
||||||
def self.render(sounds) = sounds.map { SYLLABLES.fetch(_1) }.join
|
def self.render sounds
|
||||||
|
sounds.map { SYLLABLES.fetch(_1) { raise Unconvertible } }.join
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+7
-7
@@ -1,8 +1,8 @@
|
|||||||
module TagNameTranscription
|
module TagNameTranscription
|
||||||
module JapaneseSegments
|
module NipponeseSegments
|
||||||
JAPANESE_SCRIPTS = [:kanji, :hiragana, :katakana].freeze
|
NIPPONESE_SCRIPTS = [:kanzi, :xiragana, :katakana].freeze
|
||||||
|
|
||||||
def self.japanese?(script) = JAPANESE_SCRIPTS.include?(script)
|
def self.nipponese?(script) = NIPPONESE_SCRIPTS.include?(script)
|
||||||
|
|
||||||
def self.split name
|
def self.split name
|
||||||
segments = []
|
segments = []
|
||||||
@@ -18,13 +18,13 @@ module TagNameTranscription
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.classify character, previous_script
|
def self.classify character, previous_script
|
||||||
return previous_script if character == 'ー' && japanese?(previous_script)
|
return previous_script if character == 'ー' && nipponese?(previous_script)
|
||||||
return :kanji if character.match?(/\A\p{Han}\z/)
|
return :kanzi if character.match?(/\A\p{Han}\z/)
|
||||||
return :hiragana if character.match?(/\A\p{Hiragana}\p{M}*\z/)
|
return :xiragana if character.match?(/\A\p{Hiragana}\p{M}*\z/)
|
||||||
return :katakana if character.match?(/\A\p{Katakana}\p{M}*\z/)
|
return :katakana if character.match?(/\A\p{Katakana}\p{M}*\z/)
|
||||||
return :literal if character.match?(/\A(?:\p{Latin}\p{M}*|[!-~])\z/)
|
return :literal if character.match?(/\A(?:\p{Latin}\p{M}*|[!-~])\z/)
|
||||||
|
|
||||||
raise Unconvertible
|
:unconvertible
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -24,13 +24,36 @@ module TagNameTranscription
|
|||||||
'ㇴ' => ['n'],
|
'ㇴ' => ['n'],
|
||||||
'ㇺ' => ['m'],
|
'ㇺ' => ['m'],
|
||||||
'ㇻㇼㇽㇾㇿ' => ['r:a', 'r:i', 'r:u', 'r:e', 'r:o'] }.freeze
|
'ㇻㇼㇽㇾㇿ' => ['r:a', 'r:i', 'r:u', 'r:e', 'r:o'] }.freeze
|
||||||
|
|
||||||
SOUNDS = ROWS.flat_map { |characters, sounds| characters.chars.zip(sounds) }.to_h.freeze
|
SOUNDS = ROWS.flat_map { |characters, sounds| characters.chars.zip(sounds) }.to_h.freeze
|
||||||
|
|
||||||
|
DIGRAPHS = {
|
||||||
|
'シャ' => 'sa', 'シュ' => 'su', 'シェ' => 'se', 'ショ' => 'so',
|
||||||
|
'チャ' => 'ca', 'チュ' => 'cu', 'チェ' => 'ce', 'チョ' => 'co',
|
||||||
|
'ジャ' => 'ca', 'ジュ' => 'cu', 'ジェ' => 'ce', 'ジョ' => 'co',
|
||||||
|
'ヂャ' => 'ca', 'ヂュ' => 'cu', 'ヂェ' => 'ce', 'ヂョ' => 'co' }.freeze
|
||||||
|
|
||||||
def self.adapt reading
|
def self.adapt reading
|
||||||
kana = reading.unicode_normalize(:nfc).tr('ぁ-ゖ', 'ァ-ヶ').tr('ャュョ', 'ヤユヨ')
|
kana = reading.unicode_normalize(:nfc).tr('ぁ-ゖ', 'ァ-ヶ').delete('ー')
|
||||||
kana.delete('ー').scan(/\X/).map { |character|
|
characters = kana.scan(/\X/)
|
||||||
character == 'ㇷ゚' ? 'p' : SOUNDS.fetch(character) { raise Unconvertible }
|
sounds = []
|
||||||
}
|
index = 0
|
||||||
|
|
||||||
|
while index < characters.length
|
||||||
|
pair = characters[index, 2].join
|
||||||
|
|
||||||
|
if DIGRAPHS.key?(pair)
|
||||||
|
sounds << DIGRAPHS.fetch(pair)
|
||||||
|
index += 2
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
character = characters[index].tr('ャュョ', 'ヤユヨ')
|
||||||
|
sounds << (character == 'ㇷ゚' ? 'p' : SOUNDS[character])
|
||||||
|
index += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
sounds
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
|
resources :locales, only: [:index]
|
||||||
|
|
||||||
resources :tag_groups, only: [:index, :show, :create, :update]
|
resources :tag_groups, only: [:index, :show, :create, :update]
|
||||||
|
|
||||||
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
||||||
|
|||||||
@@ -11,11 +11,6 @@
|
|||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
||||||
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
||||||
locale = Locale.find_or_initialize_by(code: 'ain-Kana')
|
|
||||||
locale.assign_attributes(language_code: 'ain', script_code: 'Kana', region_code: 392,
|
|
||||||
variants: '', name: 'ain-Kana', deprecated_at: nil)
|
|
||||||
locale.save!
|
|
||||||
TagNameCompletion.for_locale!(locale)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
post_url_sanitisation_rules = [
|
post_url_sanitisation_rules = [
|
||||||
|
|||||||
+30
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
|
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
|
||||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||||
import { createBrowserRouter,
|
import { createBrowserRouter,
|
||||||
@@ -14,7 +15,8 @@ import TopNav from '@/components/TopNav'
|
|||||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { apiPost, isApiError } from '@/lib/api'
|
import { apiPost, isApiError } from '@/lib/api'
|
||||||
import { isLocaleCode, legacyLocalePath, switchLocalePath } from '@/lib/locales'
|
import { fetchLocales, isLocaleCode, legacyLocalePath, switchLocalePath } from '@/lib/locales'
|
||||||
|
import { localesKeys } from '@/lib/queryKeys'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { applyClientAnimationMode,
|
import { applyClientAnimationMode,
|
||||||
applyClientAppearance,
|
applyClientAppearance,
|
||||||
@@ -65,6 +67,7 @@ import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
|||||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||||
|
|
||||||
import type { ClientAnimationMode } from '@/lib/settings'
|
import type { ClientAnimationMode } from '@/lib/settings'
|
||||||
|
import type { AvailableLocale } from '@/lib/locales'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
@@ -131,7 +134,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const RoutedApp: FC = () => {
|
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
||||||
const [user, setUser] = useState<User | null> (null)
|
const [user, setUser] = useState<User | null> (null)
|
||||||
const [status, setStatus] = useState (200)
|
const [status, setStatus] = useState (200)
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
@@ -246,7 +249,7 @@ const RoutedApp: FC = () => {
|
|||||||
layout={animationMode === 'off' ? false : 'position'}
|
layout={animationMode === 'off' ? false : 'position'}
|
||||||
transition={{ layout: appLayoutTransition }}
|
transition={{ layout: appLayoutTransition }}
|
||||||
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
||||||
<TopNav user={user}/>
|
<TopNav user={user} locales={locales}/>
|
||||||
<RouteTransitionWrapper
|
<RouteTransitionWrapper
|
||||||
animationMode={animationMode}
|
animationMode={animationMode}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -265,10 +268,32 @@ const RoutedApp: FC = () => {
|
|||||||
const LocaleRoute: FC = () => {
|
const LocaleRoute: FC = () => {
|
||||||
const { locale } = useParams ()
|
const { locale } = useParams ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
if (isLocaleCode (locale))
|
const { data, isPending, isError, refetch } = useQuery ({
|
||||||
return <RoutedApp/>
|
queryKey: localesKeys.index,
|
||||||
|
queryFn: fetchLocales,
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
refetchOnWindowFocus: 'always',
|
||||||
|
refetchOnReconnect: 'always' })
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
void refetch ()
|
||||||
|
}, [location.key, refetch])
|
||||||
|
|
||||||
const redirect = legacyLocalePath (location)
|
const redirect = legacyLocalePath (location)
|
||||||
|
if (!(isLocaleCode (locale)) && redirect == null)
|
||||||
|
return <NotFound/>
|
||||||
|
if (isError)
|
||||||
|
return <ServiceUnavailable/>
|
||||||
|
if (isPending)
|
||||||
|
return null
|
||||||
|
if (isLocaleCode (locale))
|
||||||
|
{
|
||||||
|
return data.locales.some (entry => entry.code === locale)
|
||||||
|
? <RoutedApp locales={data.locales}/>
|
||||||
|
: <NotFound/>
|
||||||
|
}
|
||||||
|
|
||||||
return redirect == null ? <NotFound/> : <Navigate to={redirect} replace/>
|
return redirect == null ? <NotFound/> : <Navigate to={redirect} replace/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -8,9 +9,7 @@ import Separator from '@/components/MenuSeparator'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import TopNavUser from '@/components/TopNavUser'
|
import TopNavUser from '@/components/TopNavUser'
|
||||||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||||||
import { LOCALE_LABELS,
|
import { isLocaleCode,
|
||||||
SUPPORTED_LOCALES,
|
|
||||||
isLocaleCode,
|
|
||||||
localeFromPath,
|
localeFromPath,
|
||||||
switchLocalePath,
|
switchLocalePath,
|
||||||
unlocalisedPath } from '@/lib/locales'
|
unlocalisedPath } from '@/lib/locales'
|
||||||
@@ -25,9 +24,10 @@ import { fetchWikiPage } from '@/lib/wiki'
|
|||||||
|
|
||||||
import type { FC, MouseEvent } from 'react'
|
import type { FC, MouseEvent } from 'react'
|
||||||
|
|
||||||
|
import type { AvailableLocale } from '@/lib/locales'
|
||||||
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null; locales: AvailableLocale[] }
|
||||||
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
||||||
|
|
||||||
|
|
||||||
@@ -116,8 +116,9 @@ export const menuOutline = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const TopNav: FC<Props> = ({ user }) => {
|
const TopNav: FC<Props> = ({ user, locales }) => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const localeName = locales.find (entry => entry.code === locale)?.name
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
@@ -229,7 +230,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
if (layout == null || bar == null || primary == null)
|
if (layout == null || bar == null || primary == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
const userLink = bar.querySelector<HTMLElement> ('.top-nav-user-desktop')
|
const account = bar.querySelector<HTMLElement> ('.top-nav-account')
|
||||||
let raf = 0
|
let raf = 0
|
||||||
let active = true
|
let active = true
|
||||||
|
|
||||||
@@ -252,9 +253,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
layout.setAttribute ('data-density', candidate)
|
layout.setAttribute ('data-density', candidate)
|
||||||
const style = getComputedStyle (bar)
|
const style = getComputedStyle (bar)
|
||||||
const required = primary.getBoundingClientRect ().width
|
const required = primary.getBoundingClientRect ().width
|
||||||
+ (userLink?.getBoundingClientRect ().width ?? 0)
|
+ (account?.getBoundingClientRect ().width ?? 0)
|
||||||
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
|
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
|
||||||
+ (userLink == null ? 0 : parseFloat (style.columnGap))
|
+ (account == null ? 0 : parseFloat (style.columnGap))
|
||||||
if (required <= bar.clientWidth)
|
if (required <= bar.clientWidth)
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
@@ -283,8 +284,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
|
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
|
||||||
observer?.observe (bar)
|
observer?.observe (bar)
|
||||||
observer?.observe (primary)
|
observer?.observe (primary)
|
||||||
if (userLink != null)
|
if (account != null)
|
||||||
observer?.observe (userLink)
|
observer?.observe (account)
|
||||||
update ()
|
update ()
|
||||||
addEventListener ('resize', schedule)
|
addEventListener ('resize', schedule)
|
||||||
document.fonts?.addEventListener ('loadingdone', schedule)
|
document.fonts?.addEventListener ('loadingdone', schedule)
|
||||||
@@ -297,7 +298,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
removeEventListener ('resize', schedule)
|
removeEventListener ('resize', schedule)
|
||||||
document.fonts?.removeEventListener ('loadingdone', schedule)
|
document.fonts?.removeEventListener ('loadingdone', schedule)
|
||||||
}
|
}
|
||||||
}, [activeIdx, moreVsbl, desktop, density, user?.name])
|
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||||
@@ -309,6 +310,32 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
setOpenItemIdx (activeIdx)
|
setOpenItemIdx (activeIdx)
|
||||||
}, [activeIdx, location])
|
}, [activeIdx, location])
|
||||||
|
|
||||||
|
const localeSelector = (
|
||||||
|
<label className="top-nav-menu-link relative flex h-8 shrink-0 items-center gap-1
|
||||||
|
rounded px-1 focus-within:ring-2">
|
||||||
|
<Globe aria-hidden="true" size={16}/>
|
||||||
|
<span className="top-nav-locale-name whitespace-nowrap text-sm">
|
||||||
|
{localeName}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
aria-label={localeName}
|
||||||
|
value={locale}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
onChange={event => {
|
||||||
|
const nextLocale = event.target.value
|
||||||
|
if (isLocaleCode (nextLocale)
|
||||||
|
&& locales.some (entry => entry.code === nextLocale))
|
||||||
|
{
|
||||||
|
navigate (switchLocalePath (location, nextLocale))
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{locales.map (entry => isLocaleCode (entry.code) && (
|
||||||
|
<option key={entry.code} value={entry.code}>
|
||||||
|
{entry.name}
|
||||||
|
</option>))}
|
||||||
|
</select>
|
||||||
|
</label>)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
|
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
|
||||||
data-desktop={desktop} data-density={density}>
|
data-desktop={desktop} data-density={density}>
|
||||||
@@ -374,35 +401,25 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TopNavUser user={user}/>
|
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
|
||||||
|
<TopNavUser user={user}/>
|
||||||
|
{localeSelector}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
|
||||||
type="button"
|
{menuOpen && localeSelector}
|
||||||
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
<button
|
||||||
ml-auto border-0 bg-transparent pr-4"
|
type="button"
|
||||||
onClick={() => {
|
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
||||||
setMenuOpen (!(menuOpen))
|
border-0 bg-transparent pr-4"
|
||||||
}}>
|
onClick={() => {
|
||||||
{menuOpen ? '×' : 'Menu'}
|
setMenuOpen (!(menuOpen))
|
||||||
</button>
|
}}>
|
||||||
|
{menuOpen ? '×' : 'Menu'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex min-w-0 justify-end px-2">
|
|
||||||
<select
|
|
||||||
aria-label={LOCALE_LABELS[locale]}
|
|
||||||
value={locale}
|
|
||||||
className="max-w-full rounded border border-gray-300 bg-white text-xs text-gray-900
|
|
||||||
dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
|
|
||||||
onChange={event => {
|
|
||||||
const nextLocale = event.target.value
|
|
||||||
if (isLocaleCode (nextLocale))
|
|
||||||
navigate (switchLocalePath (location, nextLocale))
|
|
||||||
}}>
|
|
||||||
{SUPPORTED_LOCALES.map (code => (
|
|
||||||
<option key={code} value={code}>{LOCALE_LABELS[code]}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key="submenu-shell"
|
key="submenu-shell"
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sp && <Separator/>}
|
{sp && <Separator/>}
|
||||||
<PrefetchLink to="/users/settings"
|
<PrefetchLink to="/users/settings" className={className}>
|
||||||
className={className}>
|
|
||||||
{user.name || '名もなきニジラー'}
|
{user.name || '名もなきニジラー'}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</>)
|
</>)
|
||||||
|
|||||||
+31
-1
@@ -216,6 +216,7 @@ body
|
|||||||
|
|
||||||
.top-nav-desktop-menu,
|
.top-nav-desktop-menu,
|
||||||
.top-nav-desktop-submenu,
|
.top-nav-desktop-submenu,
|
||||||
|
.top-nav-layout .top-nav-account,
|
||||||
.top-nav-user-desktop
|
.top-nav-user-desktop
|
||||||
{
|
{
|
||||||
display: none;
|
display: none;
|
||||||
@@ -242,8 +243,33 @@ body
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-nav-account .top-nav-user-desktop
|
||||||
|
{
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 24vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-locale-name
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px)
|
@media (min-width: 768px)
|
||||||
{
|
{
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-locale-name,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-locale-name
|
||||||
|
{
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-account .top-nav-user-desktop,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-account .top-nav-user-desktop
|
||||||
|
{
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.top-nav-layout .top-nav-brand-link
|
.top-nav-layout .top-nav-brand-link
|
||||||
{
|
{
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
@@ -255,7 +281,9 @@ body
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-nav-layout[data-desktop='true'] .top-nav-desktop-menu,
|
.top-nav-layout[data-desktop='true'] .top-nav-desktop-menu,
|
||||||
.top-nav-layout[data-measuring] .top-nav-desktop-menu
|
.top-nav-layout[data-measuring] .top-nav-desktop-menu,
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-account,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-account
|
||||||
{
|
{
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -269,6 +297,8 @@ body
|
|||||||
|
|
||||||
.top-nav-layout[data-desktop='true'] .top-nav-mobile-toggle,
|
.top-nav-layout[data-desktop='true'] .top-nav-mobile-toggle,
|
||||||
.top-nav-layout[data-measuring] .top-nav-mobile-toggle,
|
.top-nav-layout[data-measuring] .top-nav-mobile-toggle,
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-mobile-controls,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-mobile-controls,
|
||||||
.top-nav-layout[data-desktop='true'] .top-nav-mobile-menu,
|
.top-nav-layout[data-desktop='true'] .top-nav-mobile-menu,
|
||||||
.top-nav-layout[data-desktop='true'] .top-nav-user-mobile
|
.top-nav-layout[data-desktop='true'] .top-nav-user-mobile
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
import { apiGet } from '@/lib/api'
|
||||||
|
|
||||||
import type { To } from 'react-router-dom'
|
import type { To } from 'react-router-dom'
|
||||||
|
|
||||||
export const SUPPORTED_LOCALES = ['ja', 'ain-Kana'] as const
|
export const SUPPORTED_LOCALES = ['ja', 'ain-Kana'] as const
|
||||||
|
|
||||||
export type LocaleCode = typeof SUPPORTED_LOCALES[number]
|
export type LocaleCode = typeof SUPPORTED_LOCALES[number]
|
||||||
|
|
||||||
|
export type AvailableLocale = { code: string; name: string }
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchLocales = (): Promise<{ locales: AvailableLocale[] }> =>
|
||||||
|
apiGet ('/locales')
|
||||||
|
|
||||||
|
|
||||||
export const LOCALE_LABELS: Record<LocaleCode, string> = {
|
export const LOCALE_LABELS: Record<LocaleCode, string> = {
|
||||||
ja: '日本語', 'ain-Kana': 'ヤウンモシルン アイヌ イタㇰ' }
|
ja: '日本語', 'ain-Kana': 'ヤウンモシルン アイヌ イタㇰ' }
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type {
|
|||||||
MaterialFilter,
|
MaterialFilter,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
|
export const localesKeys = {
|
||||||
|
root: ['locales'] as const,
|
||||||
|
index: ['locales', 'index'] as const }
|
||||||
|
|
||||||
export const postsKeys = {
|
export const postsKeys = {
|
||||||
root: ['posts'] as const,
|
root: ['posts'] as const,
|
||||||
index: (
|
index: (
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする