このコミットが含まれているのは:
@@ -35,8 +35,18 @@ class TagVersionsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def serialise_versions rows, language_code
|
def serialise_versions rows, language_code
|
||||||
|
rows = rows.to_a
|
||||||
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||||
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
||||||
|
parent_ids = rows.flat_map do |row|
|
||||||
|
split_parent_tag_ids(row.parent_tag_ids) +
|
||||||
|
split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
|
||||||
|
end.uniq
|
||||||
|
parents_by_id = Tag.includes(
|
||||||
|
:tag_names, :materials, :deerjikists, :tag_descriptions,
|
||||||
|
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
|
||||||
|
.where(id: parent_ids).index_by(&:id)
|
||||||
|
parent_json = parents_by_id.transform_values { TagRepr.base(_1, language_code:) }
|
||||||
|
|
||||||
rows.map do |row|
|
rows.map do |row|
|
||||||
current_names = names_for_locale(row.names, language_code)
|
current_names = names_for_locale(row.names, language_code)
|
||||||
@@ -47,18 +57,10 @@ class TagVersionsController < ApplicationController
|
|||||||
cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids)
|
cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids)
|
||||||
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
|
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
|
||||||
|
|
||||||
all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids)
|
|
||||||
|
|
||||||
tags_by_id =
|
|
||||||
Tag
|
|
||||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
|
||||||
.where(id: all_parent_tag_ids)
|
|
||||||
.index_by(&:id)
|
|
||||||
|
|
||||||
parent_tags =
|
parent_tags =
|
||||||
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
|
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
|
||||||
.map do |h|
|
.map do |h|
|
||||||
{ tag: TagRepr.base(tags_by_id[h[:tag_id]], language_code:),
|
{ tag: parent_json.fetch(h[:tag_id]),
|
||||||
type: h[:type] }
|
type: h[:type] }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ class TagVersionsController < ApplicationController
|
|||||||
version_no: row.version_no,
|
version_no: row.version_no,
|
||||||
event_type: row.event_type,
|
event_type: row.event_type,
|
||||||
names: row.names,
|
names: row.names,
|
||||||
|
name_changes: name_changes(row.names, row.attributes['prev_names']),
|
||||||
name: { current: current_names['name'], prev: previous_names['name'] },
|
name: { current: current_names['name'], prev: previous_names['name'] },
|
||||||
category: { current: row.category, prev: row.attributes['prev_category'] },
|
category: { current: row.category, prev: row.attributes['prev_category'] },
|
||||||
deprecated_at: { current: row.deprecated_at&.iso8601,
|
deprecated_at: { current: row.deprecated_at&.iso8601,
|
||||||
@@ -97,11 +100,44 @@ class TagVersionsController < ApplicationController
|
|||||||
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)
|
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)
|
||||||
|
|
||||||
def names_for_locale names, language_code
|
def names_for_locale names, language_code
|
||||||
|
values = snapshot_names(names)[language_code]
|
||||||
|
values.is_a?(Hash) ? values : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def snapshot_names names
|
||||||
values = names.is_a?(String) ? JSON.parse(names) : names
|
values = names.is_a?(String) ? JSON.parse(names) : names
|
||||||
values&.fetch(language_code, {}) || {}
|
values.is_a?(Hash) ? values : {}
|
||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def name_changes current_names, previous_names
|
||||||
|
current = snapshot_names(current_names)
|
||||||
|
previous = snapshot_names(previous_names)
|
||||||
|
(current.keys | previous.keys).sort.filter_map do |language|
|
||||||
|
current_name = language_snapshot(current[language])
|
||||||
|
previous_name = language_snapshot(previous[language])
|
||||||
|
next if current_name == previous_name
|
||||||
|
|
||||||
|
{ language_code: language, current: current_name, prev: previous_name }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def language_snapshot entry
|
||||||
|
return nil unless entry.is_a?(Hash)
|
||||||
|
|
||||||
|
aliases = Array(entry['aliases']).filter_map do |value|
|
||||||
|
value = { 'name' => value } if value.is_a?(String)
|
||||||
|
next unless value.is_a?(Hash) && value['name'].is_a?(String)
|
||||||
|
|
||||||
|
{ name: value['name'], script_code: value['script_code'] }
|
||||||
|
end
|
||||||
|
aliases = aliases.uniq.sort_by { [_1[:name], _1[:script_code].to_s] }
|
||||||
|
{ name: entry['name'],
|
||||||
|
script_code: entry['script_code'],
|
||||||
|
auto_generated: entry['auto_generated'],
|
||||||
|
aliases: }
|
||||||
|
end
|
||||||
|
|
||||||
def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
|
def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -37,27 +37,18 @@ class WikiPagesController < ApplicationController
|
|||||||
def autolink_candidates
|
def autolink_candidates
|
||||||
locale = resolve_locale!
|
locale = resolve_locale!
|
||||||
language = params.fetch(:language, locale.language_code)
|
language = params.fetch(:language, locale.language_code)
|
||||||
|
language = 'ja' if language == 'und'
|
||||||
return head :not_found unless language.is_a?(String)
|
return head :not_found unless language.is_a?(String)
|
||||||
target_locale =
|
return head :not_found unless Language.exists?(code: language)
|
||||||
if language == 'und'
|
|
||||||
locale
|
|
||||||
else
|
|
||||||
Locale.where(language_code: language, deprecated_at: nil,
|
|
||||||
code: Locale.codes.values).order(:code).first
|
|
||||||
end
|
|
||||||
return head :not_found unless target_locale
|
|
||||||
|
|
||||||
candidates = []
|
names = TagDescription.joins(tag: :tag_names)
|
||||||
unless language == 'und'
|
.where(language_code: locale.language_code, tag_names: { language_code: language })
|
||||||
names = TagDescription.joins(tag: :tag_names)
|
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
|
||||||
.where(language_code: language, tag_names: { language_code: language })
|
candidates = names.map do |tag_id, description_id, name|
|
||||||
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
|
||||||
candidates = names.map do |tag_id, description_id, name|
|
|
||||||
WikiPageRepr.autolink_tag(target_locale, tag_id:, description_id:, name:)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
|
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
|
||||||
WikiPageRepr.autolink_group(target_locale, id:, name:, language:)
|
WikiPageRepr.autolink_group(locale, id:, name:, language:)
|
||||||
end)
|
end)
|
||||||
render json: candidates
|
render json: candidates
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
class Locale < ApplicationRecord
|
class Locale < ApplicationRecord
|
||||||
attr_accessor :certified_tag_names_path
|
attr_accessor :certified_tag_names_path
|
||||||
|
|
||||||
before_save :lock_tag_creation!, if: :will_generate_tag_names?
|
around_save :save_with_tag_names!, if: :will_generate_tag_names?
|
||||||
after_save :generate_tag_names!, if: :needs_tag_names?
|
|
||||||
|
|
||||||
belongs_to :language, foreign_key: :language_code, primary_key: :code
|
belongs_to :language, foreign_key: :language_code, primary_key: :code
|
||||||
belongs_to :script, foreign_key: :script_code, primary_key: :code
|
belongs_to :script, foreign_key: :script_code, primary_key: :code
|
||||||
@@ -17,11 +16,17 @@ class Locale < ApplicationRecord
|
|||||||
deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?)
|
deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?)
|
||||||
end
|
end
|
||||||
|
|
||||||
def lock_tag_creation! = SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
def save_with_tag_names!
|
||||||
|
completed = self.class.transaction(requires_new: true) do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
saved = yield
|
||||||
|
raise ActiveRecord::RecordNotSaved, 'Locale save was halted' unless saved
|
||||||
|
|
||||||
def needs_tag_names?
|
TagNameCompletion.for_locale!(self)
|
||||||
deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?)
|
true
|
||||||
|
end
|
||||||
|
unless completed
|
||||||
|
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_tag_names! = TagNameCompletion.for_locale!(self)
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ 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?
|
||||||
|
return tag_names.select { _1.language_code == language_code && !_1.primary? }
|
||||||
|
.sort_by(&:name).map(&:name)
|
||||||
|
end
|
||||||
|
|
||||||
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
|
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ module WikiPageRepr
|
|||||||
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
||||||
end
|
end
|
||||||
|
|
||||||
def autolink_tag locale, tag_id:, description_id:, name:
|
def autolink_tag locale, tag_id:, description_id:, name:, language:
|
||||||
{ resource_type: 'tag_description',
|
{ resource_type: 'tag_description',
|
||||||
resource_id: description_id,
|
resource_id: description_id,
|
||||||
tag_id:,
|
tag_id:,
|
||||||
tag_description_id: description_id,
|
tag_description_id: description_id,
|
||||||
name:,
|
name:,
|
||||||
language_code: locale.language_code,
|
language_code: language,
|
||||||
locale_code: locale.code_before_type_cast }
|
locale_code: locale.code_before_type_cast }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,19 @@ class SharedResourceIdAllocator
|
|||||||
['tag_description_versions', 'tag_id']],
|
['tag_description_versions', 'tag_id']],
|
||||||
'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze
|
'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze
|
||||||
LOCK_TIMEOUT = 10
|
LOCK_TIMEOUT = 10
|
||||||
TRANSACTION_LOCKS = ObjectSpace::WeakMap.new
|
CONNECTION_LOCKS = ObjectSpace::WeakMap.new
|
||||||
|
|
||||||
|
class LockCleanup
|
||||||
|
def initialize &finish
|
||||||
|
@finish = finish
|
||||||
|
end
|
||||||
|
|
||||||
|
def trigger_transactional_callbacks? = false
|
||||||
|
def before_committed! = nil
|
||||||
|
def committed!(**) = @finish.call(self)
|
||||||
|
def rolledback!(**) = @finish.call(self)
|
||||||
|
end
|
||||||
|
private_constant :LockCleanup
|
||||||
|
|
||||||
def self.assign! record
|
def self.assign! record
|
||||||
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
|
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
|
||||||
@@ -42,14 +54,14 @@ class SharedResourceIdAllocator
|
|||||||
raise ArgumentError, 'Unknown shared ID resource' unless SPACES.key?(space)
|
raise ArgumentError, 'Unknown shared ID resource' unless SPACES.key?(space)
|
||||||
|
|
||||||
connection = record_class.connection
|
connection = record_class.connection
|
||||||
transaction = record_class.current_transaction
|
transaction = connection.current_transaction
|
||||||
unless transaction.open?
|
unless transaction.open?
|
||||||
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
|
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
|
||||||
end
|
end
|
||||||
|
|
||||||
database = connection.pool.db_config.database
|
database = connection.pool.db_config.database
|
||||||
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
|
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
|
||||||
held_locks = TRANSACTION_LOCKS[transaction] ||= {}
|
held_locks = CONNECTION_LOCKS[connection] ||= {}
|
||||||
return if held_locks.key?(name)
|
return if held_locks.key?(name)
|
||||||
|
|
||||||
quoted_name = connection.quote(name)
|
quoted_name = connection.quote(name)
|
||||||
@@ -61,20 +73,41 @@ class SharedResourceIdAllocator
|
|||||||
end
|
end
|
||||||
held_locks[name] = true
|
held_locks[name] = true
|
||||||
|
|
||||||
release = lambda do
|
finish = lambda do |cleanup|
|
||||||
held_locks.delete(name)
|
next unless held_locks.key?(name)
|
||||||
|
|
||||||
|
# Savepoint callbacks may run before the outer transaction ends.
|
||||||
|
# Keep one session lock and transfer its cleanup to the remaining parent.
|
||||||
|
parent = connection.current_transaction
|
||||||
|
if parent.open?
|
||||||
|
connection.add_transaction_record(cleanup)
|
||||||
|
next
|
||||||
|
end
|
||||||
result = record_class.uncached do
|
result = record_class.uncached do
|
||||||
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
|
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
|
||||||
end
|
end
|
||||||
unless result.to_i == 1
|
unless result.to_i == 1
|
||||||
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
|
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
|
||||||
end
|
end
|
||||||
|
held_locks.delete(name)
|
||||||
|
rescue StandardError
|
||||||
|
# Never return a session with an untracked advisory lock to the pool.
|
||||||
|
begin
|
||||||
|
connection.disconnect!
|
||||||
|
ensure
|
||||||
|
held_locks.clear
|
||||||
|
end
|
||||||
|
raise
|
||||||
end
|
end
|
||||||
begin
|
begin
|
||||||
transaction.after_commit(&release)
|
# Rails finalises remaining records in ensure if another callback raises.
|
||||||
transaction.after_rollback(&release)
|
connection.add_transaction_record(LockCleanup.new(&finish))
|
||||||
rescue StandardError
|
rescue StandardError
|
||||||
release.call
|
begin
|
||||||
|
connection.disconnect!
|
||||||
|
ensure
|
||||||
|
held_locks.clear
|
||||||
|
end
|
||||||
raise
|
raise
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// TODO: TagSearch と共通化する.
|
// TODO: TagSearch と共通化する.
|
||||||
|
|
||||||
import { useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
@@ -46,11 +46,19 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
|||||||
|
|
||||||
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
|
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
|
||||||
const [focused, setFocused] = useState (false)
|
const [focused, setFocused] = useState (false)
|
||||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
const [request, setRequest] = useState ({ query: '' })
|
||||||
|
const [result, setResult] = useState<{
|
||||||
|
locale: string
|
||||||
|
request: { query: string }
|
||||||
|
tags: Tag[] }> ({ locale, request, tags: [] })
|
||||||
|
const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token
|
||||||
|
const suggestions = (result.locale === locale && result.request === request
|
||||||
|
&& request.query === currentToken) ? result.tags : []
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||||
|
|
||||||
const handleTagSelect = (tag: Tag) => {
|
const handleTagSelect = (tag: Tag) => {
|
||||||
setSuggestionsVsbl (false)
|
setSuggestionsVsbl (false)
|
||||||
|
setRequest ({ query: '' })
|
||||||
const textarea = ref.current!
|
const textarea = ref.current!
|
||||||
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
|
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
|
||||||
setTags (newValue)
|
setTags (newValue)
|
||||||
@@ -62,8 +70,10 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const recompute = async (pos: number, v: string = tags) => {
|
const recompute = (pos: number, v: string = tags) => {
|
||||||
const { start, end, token } = getTokenAt (v, pos)
|
const { start, end, token } = getTokenAt (v, pos)
|
||||||
|
setRequest ({ query: token })
|
||||||
|
setSuggestionsVsbl (token.trim () !== '')
|
||||||
if (!(token.trim ()))
|
if (!(token.trim ()))
|
||||||
{
|
{
|
||||||
setSuggestionsVsbl (false)
|
setSuggestionsVsbl (false)
|
||||||
@@ -71,15 +81,41 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBounds ({ start, end })
|
setBounds ({ start, end })
|
||||||
|
|
||||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { locale, params: {
|
|
||||||
q: token,
|
|
||||||
nico: '0' } })
|
|
||||||
const nextSuggestions = data.filter (t => t.postCount > 0)
|
|
||||||
setSuggestions (nextSuggestions)
|
|
||||||
setSuggestionsVsbl (nextSuggestions.length > 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (request.query.trim () === '')
|
||||||
|
return
|
||||||
|
const controller = new AbortController ()
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
|
||||||
|
locale, signal: controller.signal, params: { q: request.query, nico: '0' } })
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
setResult ({ locale, request,
|
||||||
|
tags: tags.filter (tag => tag.postCount > 0) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (active)
|
||||||
|
setResult ({ locale, request, tags: [] })
|
||||||
|
}
|
||||||
|
}) ()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
controller.abort ()
|
||||||
|
}
|
||||||
|
}, [request, locale])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
|
setRequest ({ query: '' })
|
||||||
|
}, [locale])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
|
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
@@ -93,7 +129,7 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
|||||||
onChange={ev => setTags (ev.target.value)}
|
onChange={ev => setTags (ev.target.value)}
|
||||||
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
|
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
|
||||||
const pos = (ev.target as HTMLTextAreaElement).selectionStart
|
const pos = (ev.target as HTMLTextAreaElement).selectionStart
|
||||||
await recompute (pos)
|
await recompute (pos, (ev.target as HTMLTextAreaElement).value)
|
||||||
}}
|
}}
|
||||||
onFocus={() => setFocused (true)}
|
onFocus={() => setFocused (true)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
|
|||||||
@@ -23,25 +23,57 @@ const TagSearch: FC = () => {
|
|||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
const [activeIndex, setActiveIndex] = useState (-1)
|
||||||
const [search, setSearch] = useState ('')
|
const [search, setSearch] = useState ('')
|
||||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
const [request, setRequest] = useState ({ query: '' })
|
||||||
|
const [result, setResult] = useState<{
|
||||||
|
locale: string
|
||||||
|
request: { query: string }
|
||||||
|
tags: Tag[] }> ({ locale, request, tags: [] })
|
||||||
|
const suggestions = result.locale === locale && result.request === request
|
||||||
|
? result.tags : []
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||||
|
|
||||||
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
|
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearch (ev.target.value)
|
const value = ev.target.value
|
||||||
|
const query = value.trim ().split (' ').at (-1) ?? ''
|
||||||
const q = ev.target.value.trim ().split (' ').at (-1)
|
setSearch (value)
|
||||||
if (!(q))
|
setRequest ({ query })
|
||||||
{
|
setActiveIndex (-1)
|
||||||
setSuggestions ([])
|
setSuggestionsVsbl (query !== '')
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { locale, params: { q } })
|
|
||||||
setSuggestions (data.filter (t => t.postCount > 0))
|
|
||||||
if (suggestions.length > 0)
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (request.query === '')
|
||||||
|
return
|
||||||
|
const controller = new AbortController ()
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
|
||||||
|
locale, signal: controller.signal, params: { q: request.query } })
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
setResult ({ locale, request,
|
||||||
|
tags: tags.filter (tag => tag.postCount > 0) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (active)
|
||||||
|
setResult ({ locale, request, tags: [] })
|
||||||
|
}
|
||||||
|
}) ()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
controller.abort ()
|
||||||
|
}
|
||||||
|
}, [request, locale])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setActiveIndex (-1)
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
|
}, [locale])
|
||||||
|
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
||||||
switch (ev.key)
|
switch (ev.key)
|
||||||
{
|
{
|
||||||
@@ -85,6 +117,7 @@ const TagSearch: FC = () => {
|
|||||||
match }))
|
match }))
|
||||||
.toString () }`)
|
.toString () }`)
|
||||||
setSuggestionsVsbl (false)
|
setSuggestionsVsbl (false)
|
||||||
|
setRequest ({ query: '' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +128,8 @@ const TagSearch: FC = () => {
|
|||||||
? ('not:' + tag.name)
|
? ('not:' + tag.name)
|
||||||
: tag.name)
|
: tag.name)
|
||||||
setSearch (parts.join (' ') + ' ')
|
setSearch (parts.join (' ') + ' ')
|
||||||
setSuggestions ([])
|
setRequest ({ query: '' })
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
setActiveIndex (-1)
|
setActiveIndex (-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +138,9 @@ const TagSearch: FC = () => {
|
|||||||
const anyFlg = query.get ('match') === 'any'
|
const anyFlg = query.get ('match') === 'any'
|
||||||
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
|
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
|
||||||
setSearch (tagsQuery)
|
setSearch (tagsQuery)
|
||||||
|
setRequest ({ query: '' })
|
||||||
|
setActiveIndex (-1)
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
}, [location.search])
|
}, [location.search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -35,15 +35,16 @@ const WikiBody: FC<Props> = ({ title, body, language = 'und' }) => {
|
|||||||
const missingPage = message ('missingPage', {
|
const missingPage = message ('missingPage', {
|
||||||
url: `/wiki/new?title=${ encodeURIComponent (title) }` })
|
url: `/wiki/new?title=${ encodeURIComponent (title) }` })
|
||||||
const contentLanguage = body ? language : missingPage.language
|
const contentLanguage = body ? language : missingPage.language
|
||||||
const matchLanguage = contentLanguage === 'ain-Kana' ? 'ain' : contentLanguage
|
const matchLanguage = contentLanguage === 'und'
|
||||||
|
? 'ja' : (contentLanguage === 'ain-Kana' ? 'ain' : contentLanguage)
|
||||||
const { data } = useQuery ({
|
const { data } = useQuery ({
|
||||||
enabled: Boolean (body),
|
enabled: Boolean (body),
|
||||||
queryKey: wikiKeys.autolinkCandidates (matchLanguage, locale),
|
queryKey: wikiKeys.autolinkCandidates (matchLanguage, locale),
|
||||||
queryFn: () => fetchWikiAutolinkCandidates (matchLanguage, locale) })
|
queryFn: () => fetchWikiAutolinkCandidates (matchLanguage, locale) })
|
||||||
|
|
||||||
const remarkPlugins = useMemo (
|
const remarkPlugins = useMemo (
|
||||||
() => [() => remarkWikiAutoLink (data ?? [], matchLanguage), remarkGFM],
|
() => [() => remarkWikiAutoLink (data ?? [], matchLanguage, locale), remarkGFM],
|
||||||
[data, matchLanguage])
|
[data, matchLanguage, locale])
|
||||||
const components = {
|
const components = {
|
||||||
...((contentLanguage === 'ain' || contentLanguage === 'ain-Kana')
|
...((contentLanguage === 'ain' || contentLanguage === 'ain-Kana')
|
||||||
? ainuMDXComponents : { }),
|
? ainuMDXComponents : { }),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
@@ -27,34 +27,67 @@ const TagInput: FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
const [activeIndex, setActiveIndex] = useState (-1)
|
||||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
const [request, setRequest] = useState ({ query: '' })
|
||||||
|
const [result, setResult] = useState<{
|
||||||
|
locale: string
|
||||||
|
includeNico: boolean
|
||||||
|
request: { query: string }
|
||||||
|
tags: Tag[] }> ({ locale, includeNico, request, tags: [] })
|
||||||
|
const suggestions = (result.locale === locale && result.includeNico === includeNico
|
||||||
|
&& result.request === request
|
||||||
|
&& request.query === value.trim ().split (' ').at (-1)) ? result.tags : []
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||||
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
|
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||||
setValue (ev.target.value)
|
setValue (ev.target.value)
|
||||||
|
const query = ev.target.value.trim ().split (' ').at (-1) ?? ''
|
||||||
const q = ev.target.value.trim ().split (' ').at (-1)
|
setRequest ({ query })
|
||||||
if (!(q))
|
setActiveIndex (-1)
|
||||||
{
|
setSuggestionsVsbl (query !== '')
|
||||||
setSuggestions ([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { locale, params: {
|
|
||||||
q,
|
|
||||||
nico: includeNico ? '1' : '0' } })
|
|
||||||
const nextSuggestions = data.filter (t => t.postCount > 0)
|
|
||||||
setSuggestions (nextSuggestions)
|
|
||||||
setSuggestionsVsbl (nextSuggestions.length > 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (request.query === '')
|
||||||
|
return
|
||||||
|
const controller = new AbortController ()
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
|
||||||
|
locale, signal: controller.signal,
|
||||||
|
params: { q: request.query, nico: includeNico ? '1' : '0' } })
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
setResult ({ locale, includeNico, request,
|
||||||
|
tags: tags.filter (tag => tag.postCount > 0) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (active)
|
||||||
|
setResult ({ locale, includeNico, request, tags: [] })
|
||||||
|
}
|
||||||
|
}) ()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
controller.abort ()
|
||||||
|
}
|
||||||
|
}, [request, locale, includeNico])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setActiveIndex (-1)
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
|
setRequest ({ query: '' })
|
||||||
|
}, [locale, includeNico])
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||||
const handleTagSelect = (tag: Tag) => {
|
const handleTagSelect = (tag: Tag) => {
|
||||||
const parts = value?.split (' ')
|
const parts = value?.split (' ')
|
||||||
parts[parts.length - 1] = tag.name
|
parts[parts.length - 1] = tag.name
|
||||||
setValue (parts.join (' ') + ' ')
|
setValue (parts.join (' ') + ' ')
|
||||||
setSuggestions ([])
|
setRequest ({ query: '' })
|
||||||
setActiveIndex (-1)
|
setActiveIndex (-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ export const localeFromPath = (pathname: string): LocaleCode | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const resolveViewingLocale = (
|
||||||
|
pathname: string,
|
||||||
|
routeLocale?: unknown,
|
||||||
|
): LocaleCode =>
|
||||||
|
isLocaleCode (routeLocale) ? routeLocale : localeFromPath (pathname) ?? 'ja'
|
||||||
|
|
||||||
|
|
||||||
|
export const currentViewingLocale = (): LocaleCode =>
|
||||||
|
resolveViewingLocale (typeof window === 'undefined' ? '' : window.location.pathname)
|
||||||
|
|
||||||
|
|
||||||
export const unlocalisedPath = (pathname: string): string => {
|
export const unlocalisedPath = (pathname: string): string => {
|
||||||
const locale = localeFromPath (pathname)
|
const locale = localeFromPath (pathname)
|
||||||
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
|
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
|
||||||
|
|||||||
@@ -118,8 +118,9 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url, locale) => {
|
|||||||
|
|
||||||
if (wikiPage?.body)
|
if (wikiPage?.body)
|
||||||
{
|
{
|
||||||
const language = wikiPage.languageCode
|
const contentLanguage = wikiPage.languageCode
|
||||||
?? (wikiPage.resourceType === 'tag_description' ? localeLanguage (locale) : 'und')
|
?? (wikiPage.resourceType === 'tag_description' ? localeLanguage (locale) : 'und')
|
||||||
|
const language = contentLanguage === 'und' ? 'ja' : contentLanguage
|
||||||
await qc.prefetchQuery ({
|
await qc.prefetchQuery ({
|
||||||
queryKey: wikiKeys.autolinkCandidates (language, locale),
|
queryKey: wikiKeys.autolinkCandidates (language, locale),
|
||||||
queryFn: () => fetchWikiAutolinkCandidates (language, locale) })
|
queryFn: () => fetchWikiAutolinkCandidates (language, locale) })
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ export const tagsKeys = {
|
|||||||
nicoIndex: (
|
nicoIndex: (
|
||||||
p: FetchNicoTagsParams,
|
p: FetchNicoTagsParams,
|
||||||
locale: LocaleCode = 'ja',
|
locale: LocaleCode = 'ja',
|
||||||
) => ['tags', locale, 'nico', 'index', p] as const,
|
) => ['tags', 'nico', locale, 'index', p] as const,
|
||||||
externalShow: (
|
externalShow: (
|
||||||
id: string,
|
id: string,
|
||||||
locale: LocaleCode = 'ja',
|
locale: LocaleCode = 'ja',
|
||||||
) => ['tags', locale, 'nico', id] as const,
|
) => ['tags', 'nico', locale, id] as const,
|
||||||
show: (
|
show: (
|
||||||
name: string,
|
name: string,
|
||||||
locale: LocaleCode = 'ja',
|
locale: LocaleCode = 'ja',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Content, Parent, Root, RootContent } from 'mdast'
|
import type { Content, Parent, Root, RootContent } from 'mdast'
|
||||||
|
|
||||||
import type { WikiAutolinkCandidate } from '@/types'
|
import type { WikiAutolinkCandidate } from '@/types'
|
||||||
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
const escapeForRegExp = (s: string) => s.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')
|
const escapeForRegExp = (s: string) => s.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
|
||||||
@@ -34,12 +35,14 @@ const edgeCharacter = (node: Content, first: boolean): string => {
|
|||||||
const remarkWikiAutoLink = (
|
const remarkWikiAutoLink = (
|
||||||
candidates: WikiAutolinkCandidate[],
|
candidates: WikiAutolinkCandidate[],
|
||||||
language: string,
|
language: string,
|
||||||
|
locale: LocaleCode,
|
||||||
): ((tree: Root) => void) => {
|
): ((tree: Root) => void) => {
|
||||||
const targets = new Map<string, WikiAutolinkCandidate> ()
|
const targets = new Map<string, WikiAutolinkCandidate> ()
|
||||||
const ambiguous = new Set<string> ()
|
const ambiguous = new Set<string> ()
|
||||||
for (const candidate of candidates)
|
for (const candidate of candidates)
|
||||||
{
|
{
|
||||||
if (candidate.languageCode !== language || candidate.name.length === 0)
|
if (candidate.languageCode !== language || candidate.localeCode !== locale
|
||||||
|
|| candidate.name.length === 0)
|
||||||
continue
|
continue
|
||||||
const previous = targets.get (candidate.name)
|
const previous = targets.get (candidate.name)
|
||||||
if (previous != null
|
if (previous != null
|
||||||
@@ -89,7 +92,7 @@ const remarkWikiAutoLink = (
|
|||||||
parts.push ({ type: 'text', value: value.slice (last, start) })
|
parts.push ({ type: 'text', value: value.slice (last, start) })
|
||||||
parts.push ({
|
parts.push ({
|
||||||
type: 'link',
|
type: 'link',
|
||||||
url: `/${candidate.localeCode}/wiki/${encodeURIComponent (candidate.resourceId)}`,
|
url: `/${locale}/wiki/${encodeURIComponent (candidate.resourceId)}`,
|
||||||
title: null,
|
title: null,
|
||||||
children: [{ type: 'text', value: match[0] }],
|
children: [{ type: 'text', value: match[0] }],
|
||||||
data: { hProperties: { 'data-wiki': '1' } } })
|
data: { hProperties: { 'data-wiki': '1' } } })
|
||||||
|
|||||||
@@ -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 { isLocaleCode, localeFromPath, localiseTo } from '@/lib/locales'
|
import { localiseTo, resolveViewingLocale } from '@/lib/locales'
|
||||||
|
|
||||||
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
|
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import type { LocaleCode } from '@/lib/locales'
|
|||||||
export const useLocale = (): LocaleCode => {
|
export const useLocale = (): LocaleCode => {
|
||||||
const { locale } = useParams ()
|
const { locale } = useParams ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
return isLocaleCode (locale) ? locale : localeFromPath (location.pathname) ?? 'ja'
|
return resolveViewingLocale (location.pathname, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+42
-25
@@ -2,6 +2,7 @@ import { clsx } from 'clsx'
|
|||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
import { i18n } from '@/lib/i18n'
|
import { i18n } from '@/lib/i18n'
|
||||||
|
import { currentViewingLocale } from '@/lib/locales'
|
||||||
|
|
||||||
import type { ClassValue } from 'clsx'
|
import type { ClassValue } from 'clsx'
|
||||||
|
|
||||||
@@ -14,37 +15,39 @@ export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Da
|
|||||||
export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))
|
export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))
|
||||||
|
|
||||||
|
|
||||||
export const dateString = (
|
type DatePrecision = 'month' | 'day' | 'hour' | 'minute' | 'second' | null
|
||||||
d: string | Date,
|
|
||||||
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null): string =>
|
const dateFormatters = new Map<DatePrecision, Intl.DateTimeFormat> ()
|
||||||
toDate (d).toLocaleString (
|
let ainuDateFormatter: Intl.DateTimeFormat | null = null
|
||||||
'ja-JP-u-ca-japanese',
|
|
||||||
{ era: 'long',
|
|
||||||
year: 'numeric',
|
const formatDateString = (
|
||||||
month: (unknown === 'month' ? undefined : 'long'),
|
date: Date,
|
||||||
|
unknown: DatePrecision,
|
||||||
|
locale: LocaleCode,
|
||||||
|
): string => {
|
||||||
|
if (Number.isNaN (date.getTime ()))
|
||||||
|
return date.toString ()
|
||||||
|
if (locale === 'ja')
|
||||||
|
{
|
||||||
|
if (!dateFormatters.has (unknown))
|
||||||
|
{
|
||||||
|
dateFormatters.set (unknown, new Intl.DateTimeFormat ('ja-JP-u-ca-japanese', {
|
||||||
|
era: 'long', year: 'numeric',
|
||||||
|
month: unknown === 'month' ? undefined : 'long',
|
||||||
day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric',
|
day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric',
|
||||||
weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short',
|
weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short',
|
||||||
hour: unknown == null || ['second', 'minute'].includes (unknown) ? 'numeric' : undefined,
|
hour: unknown == null || ['second', 'minute'].includes (unknown) ? 'numeric' : undefined,
|
||||||
minute: unknown == null || unknown === 'second' ? 'numeric' : undefined,
|
minute: unknown == null || unknown === 'second' ? 'numeric' : undefined,
|
||||||
second: unknown == null ? 'numeric' : undefined })
|
second: unknown == null ? 'numeric' : undefined }))
|
||||||
|
}
|
||||||
|
return dateFormatters.get (unknown)!.format (date)
|
||||||
export const localisedDateString = (
|
}
|
||||||
value: string | Date,
|
ainuDateFormatter ??= new Intl.DateTimeFormat ('en-CA', {
|
||||||
locale: LocaleCode,
|
|
||||||
unknown: Parameters<typeof dateString>[1] = null,
|
|
||||||
): string => {
|
|
||||||
if (locale === 'ja')
|
|
||||||
return dateString (value, unknown)
|
|
||||||
|
|
||||||
const date = toDate (value)
|
|
||||||
if (Number.isNaN (date.getTime ()))
|
|
||||||
return dateString (value, unknown)
|
|
||||||
|
|
||||||
const parts = new Intl.DateTimeFormat ('en-CA', {
|
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
hourCycle: 'h23' }).formatToParts (date)
|
hourCycle: 'h23' })
|
||||||
|
const parts = ainuDateFormatter.formatToParts (date)
|
||||||
const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? ''
|
const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? ''
|
||||||
const omitDay = unknown != null && ['month', 'day'].includes (unknown)
|
const omitDay = unknown != null && ['month', 'day'].includes (unknown)
|
||||||
const dateParts = [part ('year'),
|
const dateParts = [part ('year'),
|
||||||
@@ -60,6 +63,20 @@ export const localisedDateString = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const dateString = (
|
||||||
|
value: string | Date,
|
||||||
|
unknown: DatePrecision = null,
|
||||||
|
locale?: LocaleCode,
|
||||||
|
): string => formatDateString (toDate (value), unknown, locale ?? currentViewingLocale ())
|
||||||
|
|
||||||
|
|
||||||
|
export const localisedDateString = (
|
||||||
|
value: string | Date,
|
||||||
|
locale: LocaleCode,
|
||||||
|
unknown: DatePrecision = null,
|
||||||
|
): string => dateString (value, unknown, locale)
|
||||||
|
|
||||||
|
|
||||||
export const originalCreatedAtString = (
|
export const originalCreatedAtString = (
|
||||||
f: string | Date | null,
|
f: string | Date | null,
|
||||||
b: string | Date | null,
|
b: string | Date | null,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
{
|
{
|
||||||
"_untranslated": [
|
"_untranslated": [
|
||||||
"exportPath"
|
"exportPath",
|
||||||
|
"restoreScope",
|
||||||
|
"nameChanges.title",
|
||||||
|
"nameChanges.script",
|
||||||
|
"nameChanges.automatic",
|
||||||
|
"nameChanges.certified"
|
||||||
],
|
],
|
||||||
"exportPath": "export_path",
|
"exportPath": "export_path",
|
||||||
"postTitle": "$t(tags:history)",
|
"postTitle": "$t(tags:history)",
|
||||||
@@ -22,6 +27,13 @@
|
|||||||
"confirmTitle": "アホシピレ エヌカㇻ",
|
"confirmTitle": "アホシピレ エヌカㇻ",
|
||||||
"confirmPost": "「{{name}}」 ウェㇽソン {{version}} ウン アホシピレ ヤ?\nピㇼカ ヤ?",
|
"confirmPost": "「{{name}}」 ウェㇽソン {{version}} ウン アホシピレ ヤ?\nピㇼカ ヤ?",
|
||||||
"confirmTag": "シロシ「{{name}}」 ウェㇽソン {{version}} ウン アホシピレ ヤ?\nピㇼカ ヤ?",
|
"confirmTag": "シロシ「{{name}}」 ウェㇽソン {{version}} ウン アホシピレ ヤ?\nピㇼカ ヤ?",
|
||||||
|
"restoreScope": "名称・別名は現在表示中の言語だけを差し戻します。全言語の名称は復元しません。",
|
||||||
|
"nameChanges": {
|
||||||
|
"title": "言語別の変更",
|
||||||
|
"script": "文字体系",
|
||||||
|
"automatic": "自動生成",
|
||||||
|
"certified": "公証済み"
|
||||||
|
},
|
||||||
"revert": "アホシピレ",
|
"revert": "アホシピレ",
|
||||||
"restored": "アホシピレ オケレ!",
|
"restored": "アホシピレ オケレ!",
|
||||||
"restoreFailed": "アホシピレ カ エアイカㇷ゚……",
|
"restoreFailed": "アホシピレ カ エアイカㇷ゚……",
|
||||||
|
|||||||
@@ -19,6 +19,13 @@
|
|||||||
"confirmTitle": "差戻の確認",
|
"confirmTitle": "差戻の確認",
|
||||||
"confirmPost": "『{{name}}』を版 {{version}} に差戻します.\nよろしいですか?",
|
"confirmPost": "『{{name}}』を版 {{version}} に差戻します.\nよろしいですか?",
|
||||||
"confirmTag": "タグ『{{name}}』を版 {{version}} に差戻します.\nよろしいですか?",
|
"confirmTag": "タグ『{{name}}』を版 {{version}} に差戻します.\nよろしいですか?",
|
||||||
|
"restoreScope": "名称・別名は現在表示中の言語だけを差し戻します。全言語の名称は復元しません。",
|
||||||
|
"nameChanges": {
|
||||||
|
"title": "言語別の変更",
|
||||||
|
"script": "文字体系",
|
||||||
|
"automatic": "自動生成",
|
||||||
|
"certified": "公証済み"
|
||||||
|
},
|
||||||
"revert": "差戻",
|
"revert": "差戻",
|
||||||
"restored": "差戻しました.",
|
"restored": "差戻しました.",
|
||||||
"restoreFailed": "差戻に失敗……",
|
"restoreFailed": "差戻に失敗……",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const TOSPage = () => {
|
|||||||
const Terms = terms[`./${ locale }.mdx`] ?? terms['./ja.mdx']
|
const Terms = terms[`./${ locale }.mdx`] ?? terms['./ja.mdx']
|
||||||
const language = terms[`./${ locale }.mdx`] != null ? locale : 'ja'
|
const language = terms[`./${ locale }.mdx`] != null ? locale : 'ja'
|
||||||
|
|
||||||
if (!terms)
|
if (!Terms)
|
||||||
throw new Error ('TOSPage: ja.mdx not found')
|
throw new Error ('TOSPage: ja.mdx not found')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { dateString } from '@/lib/utils'
|
import { dateString } from '@/lib/utils'
|
||||||
|
|
||||||
export const lastUpdatedAt = dateString ('2026-04-12', 'hour')
|
export const lastUpdatedAt = dateString ('2026-04-12', 'hour', 'ja')
|
||||||
|
|
||||||
|
|
||||||
<MainArea>
|
<MainArea>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import PageHead from '@/components/common/PageHead'
|
|||||||
import { apiPut } from '@/lib/api'
|
import { apiPut } from '@/lib/api'
|
||||||
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||||
import { clientScrollBehaviour } from '@/lib/clientAnimation'
|
import { clientScrollBehaviour } from '@/lib/clientAnimation'
|
||||||
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||||
import { fetchTagChanges } from '@/lib/tags'
|
import { fetchTagChanges } from '@/lib/tags'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
@@ -20,6 +20,36 @@ import { cn, localisedDateString } from '@/lib/utils'
|
|||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
import type { TagNameSnapshot } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
|
const renderNameSnapshot = (
|
||||||
|
snapshot: TagNameSnapshot,
|
||||||
|
language: string,
|
||||||
|
label: (key: string) => ReactNode,
|
||||||
|
) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>
|
||||||
|
{label ('name')}:
|
||||||
|
<span lang={language} className="inline-block whitespace-nowrap">{snapshot.name}</span>
|
||||||
|
</div>
|
||||||
|
{snapshot.scriptCode != null && (
|
||||||
|
<div>{label ('nameChanges.script')}:<code>{snapshot.scriptCode}</code></div>)}
|
||||||
|
{snapshot.aliases.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{label ('tags:aliases')}:
|
||||||
|
{snapshot.aliases.map (alias => (
|
||||||
|
<span key={`${alias.name}:${alias.scriptCode}`} className="mr-2">
|
||||||
|
<span lang={language} className="inline-block whitespace-nowrap">{alias.name}</span>
|
||||||
|
{alias.scriptCode != null && <code> ({alias.scriptCode})</code>}
|
||||||
|
</span>))}
|
||||||
|
</div>)}
|
||||||
|
{snapshot.autoGenerated != null && (
|
||||||
|
<div>
|
||||||
|
{label (snapshot.autoGenerated ? 'nameChanges.automatic' : 'nameChanges.certified')}
|
||||||
|
</div>)}
|
||||||
|
</div>)
|
||||||
|
|
||||||
|
|
||||||
const renderDiff = (
|
const renderDiff = (
|
||||||
diff: { current: string | null; prev: string | null },
|
diff: { current: string | null; prev: string | null },
|
||||||
@@ -119,7 +149,25 @@ const TagHistoryPage: FC = () => {
|
|||||||
<tr key={`${ change.tagId }.${ change.versionNo }`}
|
<tr key={`${ change.tagId }.${ change.versionNo }`}
|
||||||
className={cn ('even:bg-gray-100 dark:even:bg-gray-700')}>
|
className={cn ('even:bg-gray-100 dark:even:bg-gray-700')}>
|
||||||
<td className="p-2">{change.tagId}.{change.versionNo}</td>
|
<td className="p-2">{change.tagId}.{change.versionNo}</td>
|
||||||
<td className="p-2 break-all">{renderDiff (change.name)}</td>
|
<td className="p-2 break-all">
|
||||||
|
{renderDiff (change.name)}
|
||||||
|
{(change.nameChanges?.length ?? 0) > 0 && (
|
||||||
|
<div className="mt-2 space-y-2 text-sm">
|
||||||
|
<div className="font-semibold">{label ('nameChanges.title')}</div>
|
||||||
|
{change.nameChanges?.map (entry => (
|
||||||
|
<div key={entry.languageCode}>
|
||||||
|
<code>{entry.languageCode}</code>
|
||||||
|
{entry.prev != null && (
|
||||||
|
<div className="text-red-600 line-through dark:text-red-400">
|
||||||
|
{renderNameSnapshot (entry.prev, entry.languageCode, label)}
|
||||||
|
</div>)}
|
||||||
|
{entry.current != null && (
|
||||||
|
<div className="text-green-600 underline dark:text-green-400">
|
||||||
|
{renderNameSnapshot (entry.current, entry.languageCode, label)}
|
||||||
|
</div>)}
|
||||||
|
</div>))}
|
||||||
|
</div>)}
|
||||||
|
</td>
|
||||||
<td className="p-2 break-all">
|
<td className="p-2 break-all">
|
||||||
{renderDiff (change.category,
|
{renderDiff (change.category,
|
||||||
category => label (`common:categories.${category}`))}
|
category => label (`common:categories.${category}`))}
|
||||||
@@ -193,9 +241,13 @@ const TagHistoryPage: FC = () => {
|
|||||||
|
|
||||||
if (!(await dialogue.confirm ({
|
if (!(await dialogue.confirm ({
|
||||||
title: label ('confirmTitle'),
|
title: label ('confirmTitle'),
|
||||||
description: label ('confirmTag', {
|
description: (
|
||||||
name: change.name.current ?? '',
|
<>
|
||||||
version: change.versionNo }),
|
{label ('confirmTag', {
|
||||||
|
name: change.name.current ?? '', version: change.versionNo })}
|
||||||
|
<br/>
|
||||||
|
{label ('restoreScope')}
|
||||||
|
</>),
|
||||||
confirmText: label ('revert'),
|
confirmText: label ('revert'),
|
||||||
cancelText: label ('settings:actions.cancel'),
|
cancelText: label ('settings:actions.cancel'),
|
||||||
closeLabel: label ('settings:actions.close') })))
|
closeLabel: label ('settings:actions.close') })))
|
||||||
@@ -225,6 +277,7 @@ const TagHistoryPage: FC = () => {
|
|||||||
|
|
||||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||||
|
qc.invalidateQueries ({ queryKey: wikiKeys.root })
|
||||||
toast ({ description: label ('restored') })
|
toast ({ description: label ('restored') })
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -21,21 +21,42 @@ const WikiDiffPage: FC = () => {
|
|||||||
|
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
|
||||||
const [diff, setDiff] = useState<WikiPageDiff | null> (null)
|
const [result, setResult] = useState<{
|
||||||
|
key: string
|
||||||
|
diff: WikiPageDiff } | null> (null)
|
||||||
|
|
||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
const from = query.get ('from')
|
const from = query.get ('from')
|
||||||
const to = query.get ('to')
|
const to = query.get ('to')
|
||||||
|
const requestKey = JSON.stringify ([locale, id, from, to])
|
||||||
|
const diff = result?.key === requestKey ? result.diff : null
|
||||||
const displayTitle = diff
|
const displayTitle = diff
|
||||||
? `${ diff.title }${ diff.deprecatedAt != null
|
? `${ diff.title }${ diff.deprecatedAt != null
|
||||||
? message ('shared:labels.deprecatedSuffix').text : '' }`
|
? message ('shared:labels.deprecatedSuffix').text : '' }`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
const controller = new AbortController ()
|
||||||
|
let active = true
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setDiff (await apiGet<WikiPageDiff> (`/wiki/${ id }/diff`, { locale, params: { from, to } }))
|
try
|
||||||
|
{
|
||||||
|
const diff = await apiGet<WikiPageDiff> (`/wiki/${ encodeURIComponent (id ?? '') }/diff`, {
|
||||||
|
locale, signal: controller.signal, params: { from, to } })
|
||||||
|
if (active)
|
||||||
|
setResult ({ key: requestKey, diff })
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
if (active)
|
||||||
|
console.error (error)
|
||||||
|
}
|
||||||
}) ()
|
}) ()
|
||||||
}, [from, id, to, locale])
|
return () => {
|
||||||
|
active = false
|
||||||
|
controller.abort ()
|
||||||
|
}
|
||||||
|
}, [from, id, to, locale, requestKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
|
|||||||
@@ -49,11 +49,16 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const [body, setBody] = useState ('')
|
const [body, setBody] = useState ('')
|
||||||
const [loading, setLoading] = useState (true)
|
const [loading, setLoading] = useState (true)
|
||||||
|
const [loadedSource, setLoadedSource] = useState ('')
|
||||||
|
const source = `${locale}:${id}`
|
||||||
|
const pending = loading || loadedSource !== source
|
||||||
const [title, setTitle] = useState ('')
|
const [title, setTitle] = useState ('')
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<WikiFormField> ()
|
useValidationErrors<WikiFormField> ()
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (pending || !editable)
|
||||||
|
return
|
||||||
clearValidationErrors ()
|
clearValidationErrors ()
|
||||||
|
|
||||||
const formData = new FormData ()
|
const formData = new FormData ()
|
||||||
@@ -65,10 +70,11 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
await apiPut (`/wiki/${ id }`, formData,
|
await apiPut (`/wiki/${ id }`, formData,
|
||||||
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
|
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
qc.setQueryData (wikiKeys.show (title, { }, locale),
|
qc.setQueryData (wikiKeys.show (title, { }, locale),
|
||||||
(prev: WikiPage) => ({ ...prev, title, body }))
|
(prev: WikiPage | undefined) =>
|
||||||
|
prev == null ? prev : { ...prev, title, body })
|
||||||
qc.invalidateQueries ({ queryKey: wikiKeys.root })
|
qc.invalidateQueries ({ queryKey: wikiKeys.root })
|
||||||
toast ({ title: label ('shared:messages.postSuccess') })
|
toast ({ title: label ('shared:messages.postSuccess') })
|
||||||
navigate (`/wiki/${ title }`)
|
navigate (`/wiki/${ encodeURIComponent (title) }`)
|
||||||
}
|
}
|
||||||
catch (e)
|
catch (e)
|
||||||
{
|
{
|
||||||
@@ -82,14 +88,33 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
if (!(editable))
|
if (!(editable))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
const controller = new AbortController ()
|
||||||
|
let active = true
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setLoading (true)
|
setLoading (true)
|
||||||
const data = await apiGet<WikiPage> (`/wiki/${ id }`, { locale })
|
try
|
||||||
setTitle (data.title)
|
{
|
||||||
setBody (data.body)
|
const data = await apiGet<WikiPage> (`/wiki/${ encodeURIComponent (id ?? '') }`, {
|
||||||
setLoading (false)
|
locale, signal: controller.signal })
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
setTitle (data.title)
|
||||||
|
setBody (data.body)
|
||||||
|
setLoadedSource (source)
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
if (active)
|
||||||
|
console.error (error)
|
||||||
|
}
|
||||||
}) ()
|
}) ()
|
||||||
}, [editable, id, locale])
|
return () => {
|
||||||
|
active = false
|
||||||
|
controller.abort ()
|
||||||
|
}
|
||||||
|
}, [editable, id, locale, source])
|
||||||
|
|
||||||
if (!(editable))
|
if (!(editable))
|
||||||
return <Forbidden/>
|
return <Forbidden/>
|
||||||
@@ -100,7 +125,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
<div className="max-w-xl mx-auto p-4 space-y-4">
|
<div className="max-w-xl mx-auto p-4 space-y-4">
|
||||||
<h1 className="text-2xl font-bold mb-2">{label ('editPage')}</h1>
|
<h1 className="text-2xl font-bold mb-2">{label ('editPage')}</h1>
|
||||||
|
|
||||||
{loading ? label ('common:loading') : (
|
{pending ? label ('common:loading') : (
|
||||||
<>
|
<>
|
||||||
<FieldError messages={baseErrors}/>
|
<FieldError messages={baseErrors}/>
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const WikiHistoryPage: FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
|
to={`/wiki/${ change.wikiPage.id }?version=${ change.revisionId }`}>
|
||||||
{change.wikiPage.title}
|
{change.wikiPage.title}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
{change.wikiPage.deprecatedAt != null && label ('shared:labels.deprecatedSuffix')}
|
{change.wikiPage.deprecatedAt != null && label ('shared:labels.deprecatedSuffix')}
|
||||||
|
|||||||
@@ -367,10 +367,22 @@ export type Tag = {
|
|||||||
children?: Tag[]
|
children?: Tag[]
|
||||||
matchedAlias?: string | null }
|
matchedAlias?: string | null }
|
||||||
|
|
||||||
|
export type TagNameSnapshot = {
|
||||||
|
name: string | null
|
||||||
|
scriptCode: string | null
|
||||||
|
autoGenerated: boolean | null
|
||||||
|
aliases: { name: string; scriptCode: string | null }[] }
|
||||||
|
|
||||||
|
export type TagNameChange = {
|
||||||
|
languageCode: string
|
||||||
|
current: TagNameSnapshot | null
|
||||||
|
prev: TagNameSnapshot | null }
|
||||||
|
|
||||||
export type TagVersion = {
|
export type TagVersion = {
|
||||||
tagId: number
|
tagId: number
|
||||||
versionNo: number
|
versionNo: number
|
||||||
eventType: 'create' | 'update' | 'discard' | 'restore'
|
eventType: 'create' | 'update' | 'discard' | 'restore'
|
||||||
|
nameChanges?: TagNameChange[]
|
||||||
name: { current: string | null; prev: string | null }
|
name: { current: string | null; prev: string | null }
|
||||||
category: { current: Category; prev: Category | null }
|
category: { current: Category; prev: Category | null }
|
||||||
deprecatedAt: { current: string | null; prev: string | null }
|
deprecatedAt: { current: string | null; prev: string | null }
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする