コミットを比較

...

8 コミット

作成者 SHA1 メッセージ 日付
みてるぞ fecbb4b354 #34 2026-07-05 02:04:07 +09:00
みてるぞ d0ea329887 #34 2026-07-05 01:51:07 +09:00
みてるぞ 3b29c3e10e #34 2026-07-05 00:03:13 +09:00
みてるぞ 518c5fa0f2 #34 2026-07-04 21:47:16 +09:00
みてるぞ 4cc2dc5441 #34 2026-07-04 19:50:40 +09:00
みてるぞ 6a57dc53bf #34 2026-07-04 19:03:50 +09:00
みてるぞ fdf652951a 【再掲】タグの局所記載 (#351) (#380)
#353 を再開できなかったので.

Reviewed-on: #380
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-03 03:23:36 +09:00
みてるぞ f96ddb65f1 タグが見切れる問題対応 (#76) (#395)
Reviewed-on: #395
2026-07-02 23:40:01 +09:00
63個のファイルの変更3596行の追加340行の削除
+9 -2
ファイルの表示
@@ -106,6 +106,10 @@ npm run preview
- Explain risks directly.
- Prefer single quotes for strings unless interpolation or escaping makes
double quotes better.
- For Japanese text, follow the 1986 Cabinet Notice
《現代仮名遣い》 as the default orthography.
- For Japanese kanji spelling, do not apply
《当用漢字による書きかえ》; prefer the original spelling as the formal one.
- Ruby: never put a space before method-call parentheses.
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
- Ruby: never put a line break immediately before `)`.
@@ -255,8 +259,11 @@ const value =
`!==` over negating a comparison like `!(a === b)`.
- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for
simple unit-step counter updates.
- For user-facing Japanese text, prefer modern kana usage and natural current
phrasing over historical spellings or awkward literal wording.
- For user-facing Japanese text, follow the 1986 Cabinet Notice
《現代仮名遣い》 and avoid historical kana spellings unless the task
explicitly requires them.
- For user-facing Japanese kanji spelling, do not normalize to
《当用漢字による書きかえ》; prefer original forms such as `編輯`.
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
### Frontend TSX style
+5
ファイルの表示
@@ -25,6 +25,7 @@ class PostVersionsController < ApplicationController
SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
@@ -74,6 +75,10 @@ class PostVersionsController < ApplicationController
current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base']
},
video_ms: {
current: row.video_ms,
prev: row.attributes['prev_video_ms']
},
tags: build_version_tags(cur_tags, prev_tags),
original_created_from: {
current: row.original_created_from&.iso8601,
+134 -25
ファイルの表示
@@ -1,6 +1,10 @@
class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
class VideoMsParseError < ArgumentError
;
end
def index
url = params[:url].presence
title = params[:title].presence
@@ -45,7 +49,9 @@ class PostsController < ApplicationController
.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(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -97,7 +103,9 @@ class PostsController < ApplicationController
def random
post = filtered_posts.preload(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()')
.first
@@ -110,7 +118,9 @@ class PostsController < ApplicationController
post =
Post
.includes(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.find_by(id: params[:id])
return head :not_found unless post
@@ -125,7 +135,7 @@ class PostsController < ApplicationController
child_posts:,
sibling_posts:,
related:)
.merge(tags: build_tag_tree_for(post.tags))
.merge(tags: build_tag_tree_for(post))
end
def create
@@ -149,11 +159,15 @@ class PostsController < ApplicationController
ApplicationRecord.transaction do
post.save!
tags = Tag.normalise_tags!(tag_names, deny_deprecated: true)
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
{ tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
sync_post_tags!(post, tags)
post.video_ms = normalise_video_ms(tags)
validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids)
@@ -166,6 +180,10 @@ class PostsController < ApplicationController
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e
@@ -224,6 +242,8 @@ class PostsController < ApplicationController
original_created_from:,
original_created_before:,
tag_names:,
video_ms_param: params[:video_ms],
duration_param: params[:duration],
parent_post_ids:)
snapshot_to_apply =
@@ -254,12 +274,16 @@ class PostsController < ApplicationController
post.reload
json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post.tags)
json['tags'] = build_tag_tree_for(post)
render json:, status: :ok
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e
@@ -358,7 +382,7 @@ class PostsController < ApplicationController
def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def sync_post_tags! post, desired_tags
def sync_post_tags! post, desired_tags, sections
desired_tags.each do |t|
t.save! if t.new_record?
end
@@ -377,13 +401,21 @@ class PostsController < ApplicationController
end
end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:)
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user)
end
end
def build_tag_tree_for tags
tags = tags.reject(&:deprecated?).to_a
def build_tag_tree_for post
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id)
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
@@ -398,6 +430,9 @@ class PostsController < ApplicationController
root_ids = tag_ids - child_ids
tags_by_id = tags.index_by(&:id)
sections_by_tag_id = post_tags.to_h { |post_tag|
[post_tag.tag_id, post_tag.sections.as_json(only: [:begin_ms, :end_ms])]
}
memo = { }
@@ -405,8 +440,10 @@ class PostsController < ApplicationController
tag = tags_by_id[tag_id]
return nil unless tag
sections = sections_by_tag_id.fetch(tag_id, [])
if path.include?(tag_id)
return TagRepr.inline(tag).merge(children: [])
return TagRepr.inline(tag).merge(children: [], sections:)
end
if memo.key?(tag_id)
@@ -418,7 +455,7 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.inline(tag).merge(children:)
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
end
root_ids.filter_map { |id| build_node.call(id, []) }
@@ -487,6 +524,7 @@ class PostsController < ApplicationController
def post_snapshot_from_version version
{ title: version.title,
video_ms: version.respond_to?(:video_ms) ? version.video_ms : nil,
original_created_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before),
tag_names: editable_tag_names_from_version(version),
@@ -499,6 +537,7 @@ class PostsController < ApplicationController
def post_snapshot_from_record post
{ title: post.title,
video_ms: post.video_ms,
original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post),
@@ -506,16 +545,41 @@ class PostsController < ApplicationController
end
def editable_tag_names_from_post post
post.tags.not_nico.where(deprecated_at: nil)
.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
post
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, parent_post_ids:
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
validate_video_sections!(video_ms, sections)
{ title:,
video_ms:,
original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before),
tag_names: incoming_tag_names_for_snapshot(tag_names),
tag_names: tags.uniq(&:id).map { |tag|
"#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
}.sort,
parent_post_ids: parent_post_ids.sort }
end
@@ -538,11 +602,8 @@ class PostsController < ApplicationController
value.to_s
end
def incoming_tag_names_for_snapshot raw_tag_names
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false,
deny_deprecated: true)
Tag.expand_parent_tags(tags).reject(&:deprecated?).map(&:name).uniq.sort
def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end
def post_conflict_json post:, base_version_no:, base_snapshot:,
@@ -563,6 +624,8 @@ class PostsController < ApplicationController
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:video_ms, '動画時間',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
@@ -626,11 +689,14 @@ class PostsController < ApplicationController
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title],
video_ms: snapshot[:video_ms],
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true)
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true,
with_sections: true) =>
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
@@ -638,14 +704,17 @@ class PostsController < ApplicationController
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
sync_post_tags!(post, tags)
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
[:title, :original_created_from, :original_created_before].map {
[:title, :video_ms, :original_created_from, :original_created_before].map {
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
@@ -689,4 +758,44 @@ class PostsController < ApplicationController
render_validation_error record
end
end
def normalise_video_ms tags, video_ms_param: params[:video_ms], duration_param: params[:duration]
return nil unless tags.any? { _1.id == Tag.video.id }
if video_ms_param.present?
video_ms = Integer(video_ms_param, exception: false)
raise VideoMsParseError unless video_ms&.positive?
return video_ms
end
return nil if duration_param.blank?
video_ms = Tag.time_to_ms!(duration_param.to_s, tag_name: '動画時間')
raise VideoMsParseError unless video_ms.positive?
video_ms
rescue Tag::SectionLiteralParseError
raise VideoMsParseError
end
def validate_video_sections! video_ms, sections
return unless video_ms
sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
if begin_ms >= video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
raise ActiveRecord::RecordInvalid, post
end
if end_ms && end_ms > video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
raise ActiveRecord::RecordInvalid, post
end
end
end
end
end
+61
ファイルの表示
@@ -0,0 +1,61 @@
class UserSettingsController < ApplicationController
wrap_parameters false
def show
return head :unauthorized unless current_user
render json: current_setting.serializable_hash
end
def update
return head :unauthorized unless current_user
raw_attributes = editable_raw_attributes
field_errors = validate_raw_attributes(raw_attributes)
return render_validation_error fields: field_errors if field_errors.present?
setting = current_setting
setting.assign_attributes(raw_attributes)
if setting.save
render json: setting.serializable_hash, status: :ok
else
render_validation_error setting
end
end
private
def current_setting
Setting.find_or_create_by!(user: current_user) do |setting|
setting.assign_attributes(Setting.defaults)
end
rescue ActiveRecord::RecordNotUnique
Setting.find_by!(user: current_user)
end
def editable_raw_attributes
request.request_parameters.slice(*Setting::EDITABLE_ATTRIBUTES)
end
def validate_raw_attributes raw_attributes
raw_attributes.each_with_object({ }) do |(key, value), errors|
next if value_matches_type?(key, value)
errors[key.to_sym] = ['値の型が不正です.']
end
end
def value_matches_type? key, value
case Setting::TYPE_BY_ATTRIBUTE.fetch(key)
when :string
value.is_a?(String)
when :integer
value.is_a?(Integer)
when :boolean
value == true || value == false
else
false
end
end
end
+37 -1
ファイルの表示
@@ -60,6 +60,7 @@ class Post < ApplicationRecord
before_validation :normalise_url
validates :url, presence: true, uniqueness: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range
validate :url_must_be_http_url
@@ -83,7 +84,42 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil)
end
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def snapshot_tag_names
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
end
def self.ms_to_time ms
total_s = ms / 1_000
s = total_s % 60
min = (total_s / 60) % 60
h = total_s / 3_600
remainder_ms = ms % 1_000
base =
if h.positive?
'%d:%02d:%02d' % [h, min, s]
else
'%d:%02d' % [min, s]
end
remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
end
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
+6
ファイルの表示
@@ -10,6 +10,12 @@ class PostTag < ApplicationRecord
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
dependent: :delete_all,
inverse_of: :post_tag
validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: {
+20
ファイルの表示
@@ -0,0 +1,20 @@
class PostTagSection < ApplicationRecord
self.primary_key = :post_id, :tag_id, :begin_ms
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
validates :begin_ms, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :end_ms, numericality: { only_integer: true, greater_than: :begin_ms },
allow_nil: true
end
+1
ファイルの表示
@@ -5,6 +5,7 @@ class PostVersion < ApplicationRecord
belongs_to :parent, class_name: 'Post', optional: true
validates :url, presence: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range
+47 -2
ファイルの表示
@@ -1,7 +1,52 @@
class Setting < ApplicationRecord
THEMES = ['system', 'light', 'dark'].freeze
# These remain in the typed settings schema to preserve existing backend
# work. The common `/users/settings` page currently surfaces only `theme`.
AUTO_FETCH_MODES = ['auto', 'manual', 'off'].freeze
WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze
STRING_ATTRIBUTES = [
'theme',
'auto_fetch_title',
'auto_fetch_thumbnail',
'wiki_editor_mode',
].freeze
INTEGER_ATTRIBUTES = [].freeze
BOOLEAN_ATTRIBUTES = [].freeze
EDITABLE_ATTRIBUTES =
(STRING_ATTRIBUTES + INTEGER_ATTRIBUTES + BOOLEAN_ATTRIBUTES).freeze
TYPE_BY_ATTRIBUTE = {
'theme' => :string,
'auto_fetch_title' => :string,
'auto_fetch_thumbnail' => :string,
'wiki_editor_mode' => :string,
}.freeze
belongs_to :user
validates :user_id, presence: true
validates :key, presence: true, length: { maximum: 255 }
validates :value, presence: true
validates :user_id, uniqueness: true
validates :theme, inclusion: { in: THEMES }
validates :auto_fetch_title, inclusion: { in: AUTO_FETCH_MODES }
validates :auto_fetch_thumbnail, inclusion: { in: AUTO_FETCH_MODES }
validates :wiki_editor_mode, inclusion: { in: WIKI_EDITOR_MODES }
def self.defaults
{
theme: 'system',
auto_fetch_title: 'manual',
auto_fetch_thumbnail: 'manual',
wiki_editor_mode: 'split',
}
end
def self.serializable_attributes
EDITABLE_ATTRIBUTES.map(&:to_sym)
end
def serializable_hash(options = nil)
super({ only: self.class.serializable_attributes }.merge(options || { }))
end
end
+112 -3
ファイルの表示
@@ -17,6 +17,16 @@ class Tag < ApplicationRecord
end
end
class SectionLiteralParseError < ArgumentError
attr_reader :tag_name, :literal
def initialize tag_name, literal
@tag_name = tag_name
@literal = literal
super("invalid section literal for tag #{ tag_name }: #{ literal }")
end
end
has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
@@ -105,26 +115,60 @@ class Tag < ApplicationRecord
def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true,
deny_deprecated: false
deny_deprecated: false,
with_sections: false
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
sections = { }
tags = tag_names.map do |name|
raw_name = name
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
name = name.sub(/\A#{ pf }/i, '')
sections_by_tag = []
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty?
sections_by_tag << normalise_section_range!(
begin_raw: match[2],
end_raw: match[3],
tag_name: name)
end
if name.include?('[') || name.include?(']')
raise SectionLiteralParseError.new(raw_name, raw_name)
end
name = TagName.canonicalise(name).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name]
end
tag.update!(category: cat) if cat && tag.category != cat
next if sections_by_tag.empty?
sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag)
sections[tag.id] = merge_section_ranges(sections[tag.id])
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
end
end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq(&:id)
tags.uniq!(&:id)
if with_sections
{ tags:, sections: }
else
tags
end
end
def self.expand_parent_tags tags
@@ -150,6 +194,45 @@ class Tag < ApplicationRecord
(result + tags).uniq { |t| t.id }
end
def self.normalise_section_range! begin_raw:, end_raw:, tag_name:
begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:)
end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:)
if end_ms
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
end_ms = begin_ms + 1 if begin_ms == end_ms
end
[begin_ms, end_ms]
end
def self.merge_section_ranges ranges
sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
merged = []
sorted_ranges.each do |begin_ms, end_ms|
if merged.empty?
merged << [begin_ms, end_ms]
next
end
last_begin_ms, last_end_ms = merged[-1]
if last_end_ms.nil? || begin_ms <= last_end_ms
merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)]
else
merged << [begin_ms, end_ms]
end
end
merged
end
def self.merge_section_end left_end_ms, right_end_ms
return nil if left_end_ms.nil? || right_end_ms.nil?
[left_end_ms, right_end_ms].max
end
def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id?
@@ -246,6 +329,32 @@ class Tag < ApplicationRecord
end
end
def self.time_to_ms! str, tag_name:
match =
case str
when /\A(?<seconds>\d+)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<minutes>\d+):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<hours>\d+):(?<minutes>[0-5]?\d):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: Regexp.last_match[:hours],
minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
end
raise SectionLiteralParseError.new(tag_name, str) unless match
total_s = match[:seconds].to_i
total_s += match[:minutes].to_i * 60 if match[:minutes]
total_s += match[:hours].to_i * 3_600 if match[:hours]
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
+1 -1
ファイルの表示
@@ -7,7 +7,7 @@ class User < ApplicationRecord
has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
has_many :settings
has_one :setting, dependent: :destroy
has_many :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy
+12 -3
ファイルの表示
@@ -8,6 +8,7 @@ module PostRepr
:url,
:title,
:thumbnail_base,
:video_ms,
:original_created_from,
:original_created_before,
:created_at,
@@ -18,7 +19,7 @@ module PostRepr
def base post, current_user = nil
json = common(post)
json['tags'] = tag_json(post.tags)
json['tags'] = tag_json(post)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
json
@@ -52,8 +53,16 @@ module PostRepr
.merge('thumbnail' => thumbnail_url(post))
end
def tag_json tags
tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) }
def tag_json post
post
.active_post_tags
.reject { _1.tag.deprecated? }
.sort_by { _1.tag.name }
.map { |post_tag|
TagRepr.inline(post_tag.tag).merge(
'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
}
end
def thumbnail_url post
+1
ファイルの表示
@@ -23,6 +23,7 @@ class PostVersionRecorder < VersionRecorder
{ title: @record.title,
url: @record.url,
thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '),
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from,
+3
ファイルの表示
@@ -81,6 +81,9 @@ Rails.application.routes.draw do
end
end
get 'users/settings', to: 'user_settings#show'
patch 'users/settings', to: 'user_settings#update'
resources :users, only: [:create, :update] do
collection do
post :verify
+9
ファイルの表示
@@ -0,0 +1,9 @@
class AddVideoMsToPosts < ActiveRecord::Migration[8.0]
def change
add_column :posts, :video_ms, :integer
add_index :posts, [:video_ms, :id], name: 'idx_posts_video_ms_id'
add_check_constraint :posts, 'video_ms IS NULL OR video_ms > 0',
name: 'chk_posts_video_ms_positive'
end
end
+19
ファイルの表示
@@ -0,0 +1,19 @@
class CreatePostTagSections < ActiveRecord::Migration[8.0]
def change
create_table :post_tag_sections, primary_key: [:post_id, :tag_id, :begin_ms] do |t|
t.references :post, null: false, foreign_key: true, index: false
t.references :tag, null: false, foreign_key: true, index: false
t.integer :begin_ms, null: false
t.integer :end_ms, null: true
t.timestamps
t.index [:post_id, :begin_ms], name: 'idx_post_tag_sections_post_id_begin_ms'
t.check_constraint 'begin_ms >= 0',
name: 'chk_post_tag_sections_begin_ms_natural'
t.check_constraint 'end_ms IS NULL OR begin_ms < end_ms',
name: 'chk_post_tag_sections_end_ms_after_begin_ms'
end
end
end
+9
ファイルの表示
@@ -0,0 +1,9 @@
class AddVideoMsToPostVersions < ActiveRecord::Migration[8.0]
def change
add_column :post_versions, :video_ms, :integer
add_index :post_versions, [:video_ms, :post_id], name: 'idx_post_versions_video_ms_post_id'
add_check_constraint :post_versions, 'video_ms IS NULL OR video_ms > 0',
name: 'chk_post_versions_video_ms_positive'
end
end
+31
ファイルの表示
@@ -0,0 +1,31 @@
class RebuildSettingsAsTypedUserSettings < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :settings, :users if foreign_key_exists?(:settings, :users)
remove_index :settings, :user_id if index_exists?(:settings, :user_id)
remove_column :settings, :key, :string if column_exists?(:settings, :key)
remove_column :settings, :value, :json if column_exists?(:settings, :value)
change_column_null :settings, :user_id, false
add_column :settings, :theme, :string, null: false, default: 'system'
add_column :settings,
:auto_fetch_title,
:string,
null: false,
default: 'manual'
add_column :settings,
:auto_fetch_thumbnail,
:string,
null: false,
default: 'manual'
add_column :settings,
:wiki_editor_mode,
:string,
null: false,
default: 'split'
add_index :settings, :user_id, unique: true
add_foreign_key :settings, :users
end
end
生成ファイル
+27 -4
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -298,6 +298,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.integer "begin_ms", null: false
t.integer "end_ms"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
t.index ["tag_id"], name: "fk_rails_8be3847903"
t.check_constraint "(`end_ms` is null) or (`begin_ms` < `end_ms`)", name: "chk_post_tag_sections_end_ms_after_begin_ms"
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
@@ -331,9 +344,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.datetime "original_created_before"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.integer "video_ms"
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id"
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
end
@@ -348,18 +364,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.datetime "original_created_before"
t.datetime "updated_at", null: false
t.integer "version_no", null: false
t.integer "video_ms"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "key", null: false
t.json "value", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_settings_on_user_id"
t.string "theme", default: "system", null: false
t.string "auto_fetch_title", default: "manual", null: false
t.string "auto_fetch_thumbnail", default: "manual", null: false
t.string "wiki_editor_mode", default: "split", null: false
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -678,6 +699,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id"
+8
ファイルの表示
@@ -0,0 +1,8 @@
FactoryBot.define do
factory :post_tag_section do
association :post
association :tag
begin_ms { 1_000 }
end_ms { 2_000 }
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :post_tag do
association :post
association :tag
end
end
+8
ファイルの表示
@@ -0,0 +1,8 @@
FactoryBot.define do
factory :post do
sequence(:url) { |n| "https://example.com/factory-post-#{ n }" }
title { 'factory post' }
thumbnail_base { nil }
uploaded_user { nil }
end
end
+41
ファイルの表示
@@ -0,0 +1,41 @@
RSpec.describe PostTag, type: :model do
describe '#sections' do
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to contain_exactly(section)
end
it 'does not load sections for another tag on the same post' do
post = create(:post)
tag = create(:tag)
other_tag = create(:tag)
post_tag = create(:post_tag, post:, tag:)
create(:post_tag_section,
post:,
tag: other_tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to be_empty
end
it 'allows open-ended sections' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: nil)
expect(section).to be_valid
expect(post_tag.sections).to contain_exactly(section)
end
end
end
+89
ファイルの表示
@@ -19,6 +19,95 @@ RSpec.describe Tag, type: :model do
expect(error.tag_names).to eq([deprecated_tag.name])
}
end
it 'rejects invalid section literals instead of treating them as zero' do
expect {
described_class.normalise_tags!(
['normalise_invalid_section[1:aa-2:00]'],
with_sections: true
)
}.to raise_error(Tag::SectionLiteralParseError)
end
it 'parses open-ended section literals' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
it 'parses omitted begin as zero' do
result = described_class.normalise_tags!(
['伊地知ニジカ[-1:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]])
end
it 'treats fully open section literals as plain tags' do
result = described_class.normalise_tags!(
['伊地知ニジカ[-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections)[tag.id]).to be_nil
end
it 'treats [0:00-] as a plain tag' do
result = described_class.normalise_tags!(
['伊地知ニジカ[0:00-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections)[tag.id]).to be_nil
end
it 'expands zero-width sections to one millisecond' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-1:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]])
end
it 'swaps reversed section boundaries' do
result = described_class.normalise_tags!(
['伊地知ニジカ[2:00-1:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]])
end
it 'merges open-ended sections over later bounded sections' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-][2:00-3:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
it 'merges adjacent bounded and open-ended sections' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-3:00][3:00-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
end
describe '.expand_parent_tags' do
+209
ファイルの表示
@@ -54,6 +54,7 @@ RSpec.describe 'Posts API', type: :request do
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
video_ms: post.video_ms,
tags: post.snapshot_tag_names.join(' '),
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
@@ -145,6 +146,22 @@ RSpec.describe 'Posts API', type: :request do
expect(all_tag_names).to include("spec_tag")
end
it 'keeps children and sections keys in non-detail tag responses' do
PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil)
get '/posts'
expect(response).to have_http_status(:ok)
hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }
expect(tag_json.fetch('children')).to eq([])
expect(tag_json.fetch('sections')).to eq([
{ 'begin_ms' => 1_000, 'end_ms' => nil }
])
end
context "when q is provided" do
it "filters posts by q (hit case)" do
get "/posts", params: { tags: "spec_tag" }
@@ -845,6 +862,198 @@ RSpec.describe 'Posts API', type: :request do
expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent')
end
it 'returns validation error for an invalid section literal' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid section literal',
url: 'https://example.com/invalid-section-literal',
tags: 'spec_tag[1:aa-2:00]',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.'
)
expect(json.fetch('errors')).to include(
'tags' => ['タグ区間の記法が不正です.']
)
end
it 'creates a video post with duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'video post',
url: 'https://example.com/video-post',
tags: '動画 spec_tag',
duration: '3:00.500',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(Post.find(json.fetch('id')).video_ms).to eq(180_500)
expect(json.fetch('video_ms')).to eq(180_500)
end
it 'creates a video post with number input duration seconds' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'video post seconds',
url: 'https://example.com/video-post-seconds',
tags: '動画 spec_tag',
duration: '180.5',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(Post.find(json.fetch('id')).video_ms).to eq(180_500)
expect(json.fetch('video_ms')).to eq(180_500)
end
it 'clears video_ms when the saved tags do not include 動画' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'non video post',
url: 'https://example.com/non-video-post',
tags: 'spec_tag',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(Post.find(json.fetch('id')).video_ms).to be_nil
end
it 'returns validation error when a bounded section exceeds duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'too long section',
url: 'https://example.com/too-long-section',
tags: '動画 伊地知ニジカ[2:50-3:10]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'video_ms' => ['タグ区間の終端が動画時間を超えてゐます.']
)
end
it 'saves open-ended sections with end_ms NULL' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'open ended section literal',
url: 'https://example.com/open-ended-section-literal',
tags: '動画 伊地知ニジカ[1:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
section = PostTagSection.find_by!(post: created_post, tag:)
expect(section.begin_ms).to eq(60_000)
expect(section.end_ms).to be_nil
end
it 'does not save sections for [-]' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'fully open section literal',
url: 'https://example.com/fully-open-section-literal',
tags: '伊地知ニジカ[-]',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
end
it 'does not save sections for [0:00-]' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'zero open ended section literal',
url: 'https://example.com/zero-open-ended-section-literal',
tags: '伊地知ニジカ[0:00-]',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
end
it 'returns end_ms null for open-ended sections in show response' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'show open ended section literal',
url: 'https://example.com/show-open-ended-section-literal',
tags: '動画 伊地知ニジカ[1:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
get "/posts/#{ json.fetch('id') }"
expect(response).to have_http_status(:ok)
tag_json = json.fetch('tags').find { |item| item['name'] == '伊地知ニジカ' }
expect(tag_json.fetch('sections')).to eq([
{ 'begin_ms' => 60_000, 'end_ms' => nil }
])
end
it 'allows open-ended sections when begin is within duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'valid open ended section literal',
url: 'https://example.com/valid-open-ended-section-literal',
tags: '動画 伊地知ニジカ[1:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
end
it 'rejects open-ended sections when begin equals duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid open ended section literal',
url: 'https://example.com/invalid-open-ended-section-literal',
tags: '動画 伊地知ニジカ[3:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'video_ms' => ['タグ区間の開始が動画時間以上です.']
)
end
context "when nico tag already exists in tags" do
before do
Tag.find_undiscard_or_create_by!(
+36
ファイルの表示
@@ -12,6 +12,12 @@ import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import {
applyClientAppearance,
fetchUserSettings,
getClientThemeMode,
seedClientThemeMode,
} from '@/lib/settings'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
@@ -104,6 +110,19 @@ const App: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
useEffect (() => {
applyClientAppearance ()
const mediaQuery = window.matchMedia ('(prefers-color-scheme: dark)')
const handleThemeChange = () => {
if (getClientThemeMode () === 'system')
applyClientAppearance ()
}
mediaQuery.addEventListener ('change', handleThemeChange)
return () => mediaQuery.removeEventListener ('change', handleThemeChange)
}, [])
useEffect (() => {
const createUser = async () => {
const data = await apiPost<{ code: string; user: User }> ('/users')
@@ -137,6 +156,23 @@ const App: FC = () => {
createUser ()
}, [])
useEffect (() => {
if (!(user))
return
void (async () => {
try
{
const settings = await fetchUserSettings ()
seedClientThemeMode (settings.theme)
applyClientAppearance ()
}
catch
{
}
}) ()
}, [user])
switch (status)
{
case 503:
+5 -2
ファイルの表示
@@ -87,10 +87,13 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
setDropRef (node)
}}
style={style}
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
className={cn (
'min-w-0 max-w-full overflow-hidden rounded select-none',
over && 'ring-2 ring-offset-2')}
{...attributes}
{...listeners}>
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
@@ -98,4 +101,4 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
</div>)
}
export default DraggableDroppableTagRow
export default DraggableDroppableTagRow
+9 -1
ファイルの表示
@@ -318,12 +318,20 @@ const MobileMaterialTreeNode: FC<{ depth?: number
<TagLink
tag={sidebarTagToTag (tag)}
title={tag.name}
truncateOnMobile
withCount={false}
withWiki={false}
to={materialPath (tag.id, materialFilter)}
className="block overflow-hidden text-ellipsis whitespace-nowrap
[max-inline-size:var(--tag-link-inline-size)]
[max-height:var(--tag-link-inline-size)]"/>
[max-height:var(--tag-link-inline-size)]
[&_.tag-marquee]:block
[&_.tag-marquee]:h-full
[&_.tag-marquee]:overflow-hidden
[&_.tag-marquee__static]:h-full
[&_.tag-marquee__static]:overflow-hidden
[&_.tag-marquee__static]:text-ellipsis
[&_.tag-marquee__static]:[text-overflow:ellipsis]"/>
</div>
{tag.hasChildren && (
<button
+23
ファイルの表示
@@ -66,4 +66,27 @@ describe ('PostEditForm', () => {
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
})
it ('preserves duration while the video tag is temporarily removed', () => {
const post = buildPost ({
videoMs: 180_500,
tags: [
buildTag ({ id: 1, name: '動画', category: 'general' }),
buildTag ({ id: 2, name: 'general-tag', category: 'general' }),
],
})
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
})
})
+42 -11
ファイルの表示
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
@@ -9,29 +9,35 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api'
import { updatePost } from '@/lib/posts'
import { inputClass } from '@/lib/utils'
import { inputClass, msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react'
import type { Post, Tag } from '@/types'
import type { Post, TagWithSections } from '@/types'
type PostFormField =
'parentPostIds' | 'tags' | 'originalCreatedAt'
'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt'
const videoMsToDurationValue = (videoMs: number | null): string =>
videoMs == null ? '' : String (videoMs / 1_000)
const tagsToStr = (tags: Tag[]): string => {
const result: Tag[] = []
const tagsToStr = (tags: TagWithSections[]): string => {
const result: Omit<TagWithSections, 'children'>[] = []
const walk = (tag: Tag) => {
const walk = (tag: TagWithSections) => {
const { children, ...rest } = tag
result.push (rest)
children?.forEach (walk)
children.forEach (walk)
}
tags.filter (t => t.category !== 'nico').forEach (walk)
return [...(new Set (result.map (t => t.name)))].join (' ')
return [...(new Set (result.map (t =>
`${ t.name }${ t.sections
.map (s => `[${ msToTime (s.beginMs) }-${ s.endMs == null ? '' : msToTime (s.endMs) }]`)
.join ('') }`)))].join (' ')
}
@@ -41,6 +47,7 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [disabled, setDisabled] = useState (false)
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] =
@@ -52,6 +59,10 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title)
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => {
@@ -63,6 +74,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
onSave ({ ...post,
versionNo: data.versionNo,
title: data.title,
videoMs: data.videoMs,
tags: data.tags,
parentPosts: data.parentPosts,
childPosts: data.childPosts,
@@ -102,6 +114,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{
// TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true })
return
@@ -110,6 +123,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
if (action === 'overwrite')
{
await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true })
return
@@ -124,6 +138,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
try
{
await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
}
@@ -134,7 +149,8 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
}
useEffect (() => {
setTags(tagsToStr (post.tags))
setTags (tagsToStr (post.tags))
setDuration (videoMsToDurationValue (post.videoMs))
}, [post])
return (
@@ -149,7 +165,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
disabled={disabled}
className={inputClass (invalid)}
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>)}
onChange={e => setTitle (e.target.value)}/>)}
</FormField>
{/* 親投稿 */}
@@ -181,6 +197,20 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{videoFlg && (
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
disabled={disabled}
className={inputClass (invalid)}
value={duration}
onChange={e => setDuration (e.target.value)}/>)}
</FormField>)}
{/* 送信 */}
<Button type="submit" disabled={disabled}>
@@ -188,4 +218,5 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
</form>)
}
export default PostEditForm
+6 -3
ファイルの表示
@@ -68,9 +68,12 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token, nico: '0' } })
setSuggestions (data.filter (t => t.postCount > 0))
setSuggestionsVsbl (suggestions.length > 0)
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q: token,
nico: '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
}
return (
+1 -1
ファイルの表示
@@ -72,4 +72,4 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
</div>)
}
export default PostList
export default PostList
+250
ファイルの表示
@@ -0,0 +1,250 @@
import { cn } from '@/lib/utils'
import { useEffect, useRef, useState } from 'react'
import type { FC } from 'react'
type Props = {
text: string
className?: string
truncateOnMobile?: boolean
title?: string }
const DESKTOP_MARQUEE_MEDIA =
'(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)'
const MARQUEE_START_DELAY_MS = 1600
const MARQUEE_END_HOLD_MS = 2400
const MARQUEE_SCROLL_PX_PER_SECOND = 43
const MIN_MARQUEE_OVERFLOW_PX = 1
const ResponsiveMarqueeText: FC<Props> = (
{ text, className, truncateOnMobile = false, title },
) => {
const outerRef = useRef<HTMLSpanElement | null> (null)
const staticRef = useRef<HTMLSpanElement | null> (null)
const animatedRef = useRef<HTMLSpanElement | null> (null)
const animationRef = useRef<Animation | null> (null)
const timeoutRef = useRef<number | null> (null)
const rafRef = useRef<number | null> (null)
const [overflowPx, setOverflowPx] = useState (0)
const [desktopMarqueeEnabled, setDesktopMarqueeEnabled] = useState (false)
const [active, setActive] = useState (false)
const [marqueeVisible, setMarqueeVisible] = useState (false)
useEffect (() => {
const outer = outerRef.current
const inner = staticRef.current
if (!(outer) || !(inner))
return
const measure = () => {
const nextOverflow = Math.max (0, Math.ceil (inner.scrollWidth - outer.clientWidth))
setOverflowPx (prev => Math.abs (prev - nextOverflow) <= 1 ? prev : nextOverflow)
}
measure ()
const resizeObserver =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver (() => {
measure ()
})
resizeObserver?.observe (outer)
resizeObserver?.observe (inner)
addEventListener ('resize', measure)
return () => {
resizeObserver?.disconnect ()
removeEventListener ('resize', measure)
}
}, [text])
useEffect (() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
return
const media = window.matchMedia (DESKTOP_MARQUEE_MEDIA)
const update = () => {
setDesktopMarqueeEnabled (media.matches)
}
update ()
media.addEventListener ('change', update)
return () => {
media.removeEventListener ('change', update)
}
}, [])
useEffect (() => {
const clearScheduled = () => {
if (timeoutRef.current != null)
{
clearTimeout (timeoutRef.current)
timeoutRef.current = null
}
if (rafRef.current != null)
{
cancelAnimationFrame (rafRef.current)
rafRef.current = null
}
animationRef.current?.cancel ()
animationRef.current = null
}
const animated = animatedRef.current
const canMarquee = (
active
&& desktopMarqueeEnabled
&& overflowPx >= MIN_MARQUEE_OVERFLOW_PX
&& animated != null)
const resetAnimated = () => {
const node = animatedRef.current
animationRef.current?.cancel ()
animationRef.current = null
node?.getAnimations?.().forEach (animation => {
animation.cancel ()
})
if (!(node))
return
node.style.transition = 'none'
node.style.transform = 'translateX(0)'
}
if (!canMarquee)
{
clearScheduled ()
resetAnimated ()
setMarqueeVisible (false)
return
}
let cancelled = false
const sleep = (ms: number) =>
new Promise<void> (resolve => {
timeoutRef.current = window.setTimeout (() => {
timeoutRef.current = null
resolve ()
}, ms)
})
const runLoop = async () => {
while (!cancelled)
{
resetAnimated ()
setMarqueeVisible (true)
await sleep (MARQUEE_START_DELAY_MS)
if (cancelled || !(animatedRef.current))
break
const moveDurationMs =
overflowPx / MARQUEE_SCROLL_PX_PER_SECOND * 1000
const node = animatedRef.current
if (typeof node.animate === 'function')
{
const animation = node.animate (
[
{ transform: 'translateX(0)' },
{ transform: `translateX(-${ overflowPx }px)` },
],
{ duration: moveDurationMs, easing: 'linear', fill: 'forwards' })
animationRef.current = animation
try
{
await animation.finished
}
catch
{
break
}
node.style.transform = `translateX(-${ overflowPx }px)`
animation.cancel ()
if (animationRef.current === animation)
animationRef.current = null
}
else
{
node.style.transition = `transform ${ moveDurationMs }ms linear`
await new Promise<void> (resolve => {
rafRef.current = requestAnimationFrame (() => {
rafRef.current = null
node.style.transform = `translateX(-${ overflowPx }px)`
resolve ()
})
})
await sleep (moveDurationMs)
node.style.transition = 'none'
node.style.transform = `translateX(-${ overflowPx }px)`
}
if (cancelled)
break
await sleep (MARQUEE_END_HOLD_MS)
if (cancelled)
break
resetAnimated ()
}
}
void runLoop ()
return () => {
cancelled = true
clearScheduled ()
resetAnimated ()
setMarqueeVisible (false)
}
}, [active, desktopMarqueeEnabled, overflowPx, text])
return (
<span
ref={outerRef}
title={title ?? text}
onMouseEnter={() => setActive (true)}
onMouseLeave={() => setActive (false)}
onFocus={() => setActive (true)}
onBlur={() => setActive (false)}
className={cn (
'tag-marquee inline-block max-w-full min-w-0 align-bottom',
(truncateOnMobile
? 'overflow-hidden text-ellipsis whitespace-nowrap'
: 'whitespace-normal [overflow-wrap:anywhere]'),
'md:overflow-hidden md:whitespace-nowrap',
className)}>
<span
ref={staticRef}
className={cn (
'tag-marquee__static block max-w-full',
(truncateOnMobile
? 'overflow-hidden text-ellipsis whitespace-nowrap'
: 'whitespace-normal [overflow-wrap:anywhere]'),
'md:overflow-hidden md:text-ellipsis md:whitespace-nowrap',
marqueeVisible && 'md:opacity-0')}>
{text}
</span>
{overflowPx >= MIN_MARQUEE_OVERFLOW_PX && (
<span
ref={animatedRef}
aria-hidden="true"
className={cn (
'tag-marquee__animated hidden md:block',
marqueeVisible ? 'md:opacity-100' : 'md:opacity-0')}>
{text}
</span>)}
</span>)
}
export default ResponsiveMarqueeText
+11 -9
ファイルの表示
@@ -26,13 +26,13 @@ import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, Tag } from '@/types'
import type { Category, Post, TagWithSections } from '@/types'
type TagByCategory = { [key in Category]: Tag[] }
type TagByCategory = { [key in Category]: TagWithSections[] }
const renderTagTree = (
tag: Tag,
tag: TagWithSections,
nestLevel: number,
path: string,
suppressClickRef: MutableRefObject<boolean>,
@@ -62,8 +62,9 @@ const renderTagTree = (
const isDescendant = (
root: Tag,
targetId: number): boolean => {
root: TagWithSections,
targetId: number,
): boolean => {
if (!(root.children))
return false
@@ -81,8 +82,9 @@ const isDescendant = (
const findTag = (
byCat: TagByCategory,
id: number): Tag | undefined => {
const walk = (nodes: Tag[]): Tag | undefined => {
id: number,
): TagWithSections | undefined => {
const walk = (nodes: TagWithSections[]): TagWithSections | undefined => {
for (const t of nodes)
{
if (t.id === id)
@@ -163,7 +165,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
}
for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[])
tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1)
tagsTmp[cat].sort ((tagA: TagWithSections, tagB: TagWithSections) => tagA.name < tagB.name ? -1 : 1)
return tagsTmp
}, [post])
@@ -373,4 +375,4 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
</SidebarComponent>)
}
export default TagDetailSidebar
export default TagDetailSidebar
+56 -24
ファイルの表示
@@ -1,14 +1,15 @@
import PrefetchLink from '@/components/PrefetchLink'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
import { cn } from '@/lib/utils'
import type { ComponentProps, FC, HTMLAttributes } from 'react'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types'
type CommonProps = {
tag: Tag
nestLevel?: number
truncateOnMobile?: boolean
withWiki?: boolean
withCount?: boolean }
@@ -30,29 +31,40 @@ type Props =
const TagLink: FC<Props> = ({ tag,
nestLevel = 0,
linkFlg = true,
truncateOnMobile = false,
withWiki = true,
withCount = true,
className,
style,
title,
...props }) => {
const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
const linkClass = cn (
spanClass,
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
const colourStyle = {
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`,
...style,
} as CSSProperties
const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
const rootClass =
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto'
const textTitle = title
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias }${ tag.name }`)
return (
<>
<span className={rootClass}>
{(linkFlg && withWiki) && (
<span className="mr-1">
<span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? (
tag.materialId == null && !(tag.hasDeerjikists)
? (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)
: (
@@ -60,13 +72,15 @@ const TagLink: FC<Props> = ({ tag,
? (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)))
: (
@@ -100,33 +114,51 @@ const TagLink: FC<Props> = ({ tag,
</span>)}
{nestLevel > 0 && (
<span
className="ml-1 mr-1"
className="ml-1 mr-1 shrink-0"
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)}
{tag.matchedAlias != null && (
<>
<span className={cn (spanClass, className)} {...props}>
{tag.matchedAlias}
<span
title={textTitle}
className={cn (spanClass, textClass, className)}
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.matchedAlias}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>
<> </>
<span className="shrink-0"> </span>
</>)}
{linkFlg
? (
<PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={cn (linkClass, className)}
title={textTitle}
className={cn (linkClass, textClass, className)}
style={colourStyle}
{...props}>
{tag.name}
<ResponsiveMarqueeText
text={tag.name}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</PrefetchLink>)
: (
<span className={cn (spanClass, className)}
<span
title={textTitle}
className={cn (spanClass, textClass, className)}
style={colourStyle}
{...props}>
{tag.name}
<ResponsiveMarqueeText
text={tag.name}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>)}
{withCount && (
<span className="ml-1">{tag.postCount}</span>)}
</>)
<span className={countClass}>{tag.postCount}</span>)}
</span>)
}
export default TagLink
+5 -3
ファイルの表示
@@ -20,12 +20,14 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
rounded shadow">
{suggestions.map ((tag, i) => (
<li key={tag.id}
className={cn ('px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
className={cn ('min-w-0 overflow-hidden px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
onMouseDown={() => onSelect (tag)}>
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
</div>
</li>))}
</ul>)
}
export default TagSearchBox
export default TagSearchBox
+3 -2
ファイルの表示
@@ -64,8 +64,9 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
<ul>
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<li key={tag.id} className="mb-1 min-w-0 max-w-full overflow-hidden">
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
@@ -128,4 +129,4 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</SidebarComponent>)
}
export default TagSidebar
export default TagSidebar
+22 -13
ファイルの表示
@@ -7,12 +7,15 @@ type TabProps = { name: string
init?: boolean
children: React.ReactNode }
type Props = { children: React.ReactNode }
type Props = {
children: React.ReactNode
rightAddon?: React.ReactNode
}
export const Tab = ({ children }: TabProps) => <>{children}</>
const TabGroup: FC<Props> = ({ children }) => {
const TabGroup: FC<Props> = ({ children, rightAddon }) => {
const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[]
const [current, setCurrent] = useState<number> (() => {
@@ -22,17 +25,23 @@ const TabGroup: FC<Props> = ({ children }) => {
return (
<div className="mt-4">
<div className="flex gap-4">
{tabs.map ((tab, i) => (
<a key={i}
href="#"
className={cn (i === current && 'font-bold')}
onClick={ev => {
ev.preventDefault ()
setCurrent (i)
}}>
{tab.props.name}
</a>))}
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 gap-4">
{tabs.map ((tab, i) => (
<a key={i}
href="#"
className={cn (i === current && 'font-bold')}
onClick={ev => {
ev.preventDefault ()
setCurrent (i)
}}>
{tab.props.name}
</a>))}
</div>
{rightAddon && (
<div className="shrink-0">
{rightAddon}
</div>)}
</div>
<div className="mt-2">
{tabs[current]}
+17 -1
ファイルの表示
@@ -26,7 +26,7 @@ describe ('TagInput', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏' } },
{ params: { q: '虹夏', nico: '0' } },
)
})
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
@@ -41,4 +41,20 @@ describe ('TagInput', () => {
expect (api.apiGet).not.toHaveBeenCalled ()
expect (setValue).toHaveBeenCalledWith (' ')
})
it ('sends nico=1 only when includeNico is true', async () => {
const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
render (<TagInput value="" setValue={setValue} includeNico={true}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏', nico: '1' } },
)
})
})
})
+14 -5
ファイルの表示
@@ -10,13 +10,20 @@ import type { Tag } from '@/types'
type Props = {
includeNico?: boolean
describedBy?: string
invalid?: boolean
value: string
setValue: (value: string) => void }
const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
const TagInput: FC<Props> = ({
includeNico = false,
describedBy,
invalid,
value,
setValue,
}) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
@@ -32,10 +39,12 @@ const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q,
nico: includeNico ? '1' : '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
+97
ファイルの表示
@@ -0,0 +1,97 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import {
applyClientAppearance,
DEFAULT_USER_SETTINGS,
fetchUserSettings,
} from '@/lib/settings'
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { User } from '@/types'
import type { UserSettings } from '@/lib/settings'
type ContextValue = {
loaded: boolean
error: string | null
settings: UserSettings
setSettings: Dispatch<SetStateAction<UserSettings>> }
const UserSettingsContext = createContext<ContextValue | null> (null)
export const UserSettingsProvider: FC<{
children: ReactNode
user: User | null }> = ({ children, user }) => {
const [error, setError] = useState<string | null> (null)
const [loaded, setLoaded] = useState (false)
const [settings, setSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
useEffect (() => {
let cancelled = false
if (!(user))
{
setError (null)
setSettings (DEFAULT_USER_SETTINGS)
setLoaded (true)
return
}
setLoaded (false)
setError (null)
void (async () => {
try
{
const next = await fetchUserSettings ()
if (!(cancelled))
{
setError (null)
setSettings (next)
setLoaded (true)
}
}
catch
{
if (!(cancelled))
{
setError ('設定を読み込めませんでした.既定値で表示しています.')
setSettings (DEFAULT_USER_SETTINGS)
setLoaded (true)
}
}
}) ()
return () => {
cancelled = true
}
}, [user])
useEffect (() => {
void settings.theme
applyClientAppearance ()
}, [settings.theme])
const value = useMemo<ContextValue> (() => ({
error,
loaded,
settings,
setSettings,
}), [error, loaded, settings])
return (
<UserSettingsContext.Provider value={value}>
{children}
</UserSettingsContext.Provider>)
}
export const useUserSettings = (): ContextValue => {
const value = useContext (UserSettingsContext)
if (value == null)
throw new Error ('UserSettingsProvider is missing')
return value
}
+83 -22
ファイルの表示
@@ -10,6 +10,10 @@
{
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
@@ -29,12 +33,32 @@
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--theme-link: 221.2 83.2% 53.3%;
--tag-colour-deerjikist: #9f1239;
--tag-colour-deerjikist-hover: #b62a51;
--tag-colour-meme: #6b21a8;
--tag-colour-meme-hover: #8238bf;
--tag-colour-character: #4d7c0f;
--tag-colour-character-hover: #649326;
--tag-colour-general: #155e75;
--tag-colour-general-hover: #2c758c;
--tag-colour-material: #c2410c;
--tag-colour-material-hover: #d95823;
--tag-colour-meta: #a16207;
--tag-colour-meta-hover: #b8791e;
--tag-colour-nico: #4b5563;
--tag-colour-nico-hover: #626c7a;
}
.dark
{
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
@@ -58,16 +82,16 @@
body
{
@apply overflow-x-clip;
@apply overflow-x-clip bg-background text-foreground;
}
a
{
@apply text-blue-700 dark:text-blue-300;
color: hsl(var(--theme-link));
}
a:hover
{
@apply text-blue-500 dark:text-blue-100;
opacity: .85;
}
}
@@ -78,8 +102,8 @@
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color: hsl(var(--foreground));
background-color: hsl(var(--background));
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -87,6 +111,29 @@
-moz-osx-font-smoothing: grayscale;
}
:root[data-font-size='small']
{
font-size: 14px;
}
:root[data-font-size='normal']
{
font-size: 16px;
}
:root[data-font-size='large']
{
font-size: 18px;
}
:root[data-display-density='compact'] input,
:root[data-display-density='compact'] textarea,
:root[data-display-density='compact'] select,
:root[data-display-density='compact'] button
{
line-height: 1.25;
}
a
{
font-weight: 500;
@@ -97,28 +144,15 @@ body
{
margin: 0;
min-width: 320px;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
#root
{
min-height: 100dvh;
}
@media (prefers-color-scheme: light)
{
:root
{
color: #213547;
background-color: #ffffff;
}
a:hover
{
color: #747bff;
}
button
{
background-color: #f9f9f9;
}
background: hsl(var(--background));
color: hsl(var(--foreground));
}
@keyframes wiki-blink
@@ -132,3 +166,30 @@ body
0%, 100% { color: #f87171; }
50% { color: #60a5fa; }
}
.tag-marquee
{
position: relative;
}
.tag-link-colour
{
color: var(--tag-link-colour);
}
.tag-link-hover-colour:hover
{
color: var(--tag-link-hover-colour) !important;
}
.tag-marquee__animated
{
position: absolute;
inset: 0 auto 0 0;
min-width: 100%;
width: max-content;
opacity: 0;
pointer-events: none;
white-space: nowrap;
will-change: transform;
}
+2
ファイルの表示
@@ -70,6 +70,7 @@ describe ('posts API functions', () => {
id: 5,
title: 'new title',
tags: 'tag',
duration: null,
parentPostIds: '1 2',
originalCreatedFrom: null,
originalCreatedBefore: '2026-01-02T00:00:00Z',
@@ -82,6 +83,7 @@ describe ('posts API functions', () => {
{
title: 'new title',
tags: 'tag',
duration: null,
parent_post_ids: '1 2',
original_created_from: null,
original_created_before: '2026-01-02T00:00:00Z',
+2
ファイルの表示
@@ -44,6 +44,7 @@ export const updatePost = async (
post: { id: number
title: string | null
tags: string
duration: string | null
parentPostIds: string
originalCreatedFrom: string | null
originalCreatedBefore: string | null },
@@ -55,6 +56,7 @@ export const updatePost = async (
`/posts/${ post.id }`,
{ title: post.title,
tags: post.tags,
duration: post.duration,
parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore },
+843
ファイルの表示
@@ -0,0 +1,843 @@
import { CATEGORIES } from '@/consts'
import { apiGet, apiPatch } from '@/lib/api'
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
export type UserSettings = {
theme: 'system' | 'light' | 'dark'
autoFetchTitle: 'auto' | 'manual' | 'off'
autoFetchThumbnail: 'auto' | 'manual' | 'off'
wikiEditorMode: 'split' | 'write' | 'preview' }
export type ThemeTagColours = Record<Category, string>
export type BuiltinThemeId = 'light' | 'dark'
export type ThemeId = BuiltinThemeId | `custom:${ string }`
export type ActiveThemeId = ThemeId | 'system'
export type ResolvedTheme = 'light' | 'dark'
export type ThemeTokens = {
background: string
foreground: string
card: string
cardForeground: string
popover: string
popoverForeground: string
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
muted: string
mutedForeground: string
accent: string
accentForeground: string
destructive: string
destructiveForeground: string
border: string
input: string
ring: string
link: string
tagColours: ThemeTagColours
}
export type ClientTheme = {
id: ThemeId
name: string
builtin: boolean
baseTheme: BuiltinThemeId
tokens: ThemeTokens }
export type CustomClientTheme =
ClientTheme & { builtin: false }
export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom'
export type TheatreTagFlow = 'vertical' | 'horizontal'
export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off'
export type ClientPaneBreakpoint = 'desktop' | 'tablet'
export type ClientListKey = 'postList' | 'postSearch' | 'tagList'
export type ClientListLimit = 20 | 50 | 100
type ClientPaneSettings = {
widthPxByBreakpoint?: Partial<Record<ClientPaneBreakpoint, number>>
collapsed?: boolean }
type ClientListSettings = {
limit?: ClientListLimit
order?: string }
export type ClientAppearanceSettings = {
activeThemeId?: ActiveThemeId
customThemes?: Record<string, CustomClientTheme>
// Legacy fields kept for migration from the earlier appearance model.
theme?: UserSettings['theme']
tagColours?: Partial<ThemeTagColours> }
// DB-backed settings. At present only `theme` is surfaced in `/users/settings`.
// The remaining fields are retained for existing backend work and are not wired
// to the common settings UI yet.
export const DEFAULT_USER_SETTINGS: UserSettings = {
theme: 'system',
autoFetchTitle: 'manual',
autoFetchThumbnail: 'manual',
wikiEditorMode: 'split' }
// Browser-local settings only. These are device or screen specific.
export type ClientSettings = {
appearance?: ClientAppearanceSettings
panes?: Record<string, ClientPaneSettings>
lists?: Partial<Record<ClientListKey, ClientListSettings>>
theatre?: {
layoutMode?: TheatreLayoutMode
tagFlow?: TheatreTagFlow
}
gekanator?: {
backgroundMotion?: GekanatorBackgroundMotionMode
}
reducedMotion?: string
embedAutoLoad?: string
thumbnailMode?: string }
export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings'
export const THEME_OPTIONS = ['system', 'light', 'dark'] as const
export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const
export const POST_LIST_ORDER_OPTIONS = [
'title:asc',
'title:desc',
'url:asc',
'url:desc',
'original_created_at:asc',
'original_created_at:desc',
'created_at:asc',
'created_at:desc',
'updated_at:asc',
'updated_at:desc',
] as const satisfies readonly FetchPostsOrder[]
export const TAG_LIST_ORDER_OPTIONS = [
'name:asc',
'name:desc',
'category:asc',
'category:desc',
'post_count:asc',
'post_count:desc',
'created_at:asc',
'created_at:desc',
'updated_at:asc',
'updated_at:desc',
] as const satisfies readonly FetchTagsOrder[]
export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20
export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc'
export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc'
export const DEFAULT_LIGHT_THEME_TAG_COLOURS: ThemeTagColours = {
deerjikist: '#9f1239',
meme: '#6b21a8',
character: '#4d7c0f',
general: '#155e75',
material: '#c2410c',
meta: '#a16207',
nico: '#4b5563',
}
export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = {
deerjikist: '#fb7185',
meme: '#d8b4fe',
character: '#bef264',
general: '#67e8f9',
material: '#fdba74',
meta: '#fde047',
nico: '#e5e7eb',
}
export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS
export const LIGHT_THEME_TOKENS: ThemeTokens = {
background: '0 0% 100%',
foreground: '222.2 84% 4.9%',
card: '0 0% 100%',
cardForeground: '222.2 84% 4.9%',
popover: '0 0% 100%',
popoverForeground: '222.2 84% 4.9%',
primary: '222.2 47.4% 11.2%',
primaryForeground: '210 40% 98%',
secondary: '210 40% 96.1%',
secondaryForeground: '222.2 47.4% 11.2%',
muted: '210 40% 96.1%',
mutedForeground: '215.4 16.3% 46.9%',
accent: '210 40% 96.1%',
accentForeground: '222.2 47.4% 11.2%',
destructive: '0 72.2% 50.6%',
destructiveForeground: '210 40% 98%',
border: '214.3 31.8% 91.4%',
input: '214.3 31.8% 91.4%',
ring: '222.2 84% 4.9%',
link: '221.2 83.2% 53.3%',
tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS,
}
export const DARK_THEME_TOKENS: ThemeTokens = {
background: '222.2 84% 4.9%',
foreground: '210 40% 98%',
card: '222.2 84% 4.9%',
cardForeground: '210 40% 98%',
popover: '222.2 84% 4.9%',
popoverForeground: '210 40% 98%',
primary: '210 40% 98%',
primaryForeground: '222.2 47.4% 11.2%',
secondary: '217.2 32.6% 17.5%',
secondaryForeground: '210 40% 98%',
muted: '217.2 32.6% 17.5%',
mutedForeground: '215 20.2% 65.1%',
accent: '217.2 32.6% 17.5%',
accentForeground: '210 40% 98%',
destructive: '0 62.8% 45%',
destructiveForeground: '210 40% 98%',
border: '217.2 32.6% 17.5%',
input: '217.2 32.6% 17.5%',
ring: '212.7 26.8% 83.9%',
link: '213.1 93.9% 67.8%',
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS,
}
export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = {
light: {
id: 'light',
name: 'ライト・モード',
builtin: true,
baseTheme: 'light',
tokens: LIGHT_THEME_TOKENS,
},
dark: {
id: 'dark',
name: 'ダーク・モード',
builtin: true,
baseTheme: 'dark',
tokens: DARK_THEME_TOKENS,
},
}
const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY =
'gekanator:background-motion:v1'
const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated'
export const fetchUserSettings = async (): Promise<UserSettings> => {
const raw = await apiGet<{
theme: UserSettings['theme']
auto_fetch_title: UserSettings['autoFetchTitle']
auto_fetch_thumbnail: UserSettings['autoFetchThumbnail']
wiki_editor_mode: UserSettings['wikiEditorMode']
}> ('/users/settings')
return {
theme: raw.theme,
autoFetchTitle: raw.auto_fetch_title,
autoFetchThumbnail: raw.auto_fetch_thumbnail,
wikiEditorMode: raw.wiki_editor_mode,
}
}
export const updateUserSettings = async (
settings: Partial<{ theme: UserSettings['theme'] }>,
): Promise<UserSettings> => {
const raw = await apiPatch<{
theme: UserSettings['theme']
auto_fetch_title: UserSettings['autoFetchTitle']
auto_fetch_thumbnail: UserSettings['autoFetchThumbnail']
wiki_editor_mode: UserSettings['wikiEditorMode']
}> ('/users/settings', settings)
return {
theme: raw.theme,
autoFetchTitle: raw.auto_fetch_title,
autoFetchThumbnail: raw.auto_fetch_thumbnail,
wikiEditorMode: raw.wiki_editor_mode,
}
}
const safeParseClientSettings = (raw: string | null): ClientSettings => {
if (!(raw))
return { }
try
{
const parsed = JSON.parse (raw)
return parsed && typeof parsed === 'object' ? parsed as ClientSettings : { }
}
catch
{
return { }
}
}
export const loadClientSettings = (): ClientSettings =>
safeParseClientSettings (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY))
export const saveClientSettings = (settings: ClientSettings): void => {
localStorage.setItem (CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify (settings))
}
export const updateClientSettings = (
updater: (settings: ClientSettings) => ClientSettings,
): ClientSettings => {
const next = updater (loadClientSettings ())
saveClientSettings (next)
return next
}
const validListLimit = (value: unknown): ClientListLimit | null =>
value === 20 || value === 50 || value === 100 ? value : null
const normaliseHexColour = (value: string): string | null =>
/^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null
const isBuiltinThemeId = (value: string): value is BuiltinThemeId =>
value === 'light' || value === 'dark'
const isCustomThemeId = (value: string): value is `custom:${ string }` =>
value.startsWith ('custom:')
const themeCopyName = (baseTheme: BuiltinThemeId): string =>
`${ BUILTIN_THEMES[baseTheme].name }のコピー`
const appearanceFromSettings = (): ClientAppearanceSettings =>
loadClientSettings ().appearance ?? { }
export const hasStoredClientThemeSelection = (): boolean => {
const appearance = appearanceFromSettings ()
return (
appearance.activeThemeId != null
|| appearance.customThemes != null
|| appearance.theme != null
|| appearance.tagColours != null
)
}
const mixHexColour = (
source: string,
target: string,
ratio: number,
): string => {
const clampedRatio = Math.max (0, Math.min (1, ratio))
const src = source.slice (1)
const dst = target.slice (1)
const channels = [0, 2, 4].map (offset => {
const srcValue = parseInt (src.slice (offset, offset + 2), 16)
const dstValue = parseInt (dst.slice (offset, offset + 2), 16)
const mixed = Math.round (
srcValue + (dstValue - srcValue) * clampedRatio,
)
return mixed.toString (16).padStart (2, '0')
})
return `#${ channels.join ('') }`
}
export const getClientThemeMode = (): UserSettings['theme'] =>
(() => {
const appearance = appearanceFromSettings ()
const customThemes = getClientCustomThemes ()
if (appearance.activeThemeId === 'system')
return 'system'
if (appearance.activeThemeId === 'light' || appearance.activeThemeId === 'dark')
return appearance.activeThemeId
if (
typeof appearance.activeThemeId === 'string'
&& customThemes[appearance.activeThemeId] != null
)
return customThemes[appearance.activeThemeId].baseTheme
return appearance.theme ?? DEFAULT_USER_SETTINGS.theme
}) ()
export const setClientThemeMode = (theme: UserSettings['theme']): void => {
updateClientSettings (settings => ({
...settings,
appearance: {
...(settings.appearance ?? { }),
activeThemeId: theme,
theme,
},
}))
}
export const seedClientThemeMode = (theme: UserSettings['theme']): void => {
if (hasStoredClientThemeSelection ())
return
setClientThemeMode (theme)
}
const prefersDarkTheme = (): boolean =>
window.matchMedia ('(prefers-color-scheme: dark)').matches
export const resolveThemeMode = (
theme: UserSettings['theme'],
): ResolvedTheme =>
theme === 'system'
? (prefersDarkTheme () ? 'dark' : 'light')
: theme
export const defaultThemeTagColoursFor = (
theme: ResolvedTheme,
): ThemeTagColours =>
theme === 'dark'
? DEFAULT_DARK_THEME_TAG_COLOURS
: DEFAULT_LIGHT_THEME_TAG_COLOURS
const normaliseThemeTagColours = (
input: Partial<ThemeTagColours> | null | undefined,
fallback: ThemeTagColours,
): ThemeTagColours =>
CATEGORIES.reduce<ThemeTagColours> (
(colours, category) => {
const candidate = input?.[category]
colours[category] =
typeof candidate === 'string' && normaliseHexColour (candidate) != null
? candidate
: fallback[category]
return colours
},
{ ...fallback },
)
const normaliseCustomTheme = (
rawTheme: unknown,
): CustomClientTheme | null => {
if (!(rawTheme) || typeof rawTheme !== 'object')
return null
const theme = rawTheme as Record<string, unknown>
const id = typeof theme.id === 'string' ? theme.id : null
const name = typeof theme.name === 'string' ? theme.name : null
const baseTheme = typeof theme.baseTheme === 'string' ? theme.baseTheme : null
if (
id == null
|| !(isCustomThemeId (id))
|| name == null
|| baseTheme == null
|| !(isBuiltinThemeId (baseTheme))
)
return null
return {
id,
name,
builtin: false,
baseTheme,
tokens: {
...(
baseTheme === 'dark'
? DARK_THEME_TOKENS
: LIGHT_THEME_TOKENS
),
...(theme.tokens && typeof theme.tokens === 'object'
? theme.tokens as Partial<ThemeTokens>
: { }),
tagColours: normaliseThemeTagColours (
(
theme.tokens
&& typeof theme.tokens === 'object'
&& 'tagColours' in theme.tokens
)
? (theme.tokens as { tagColours?: Partial<ThemeTagColours> }).tagColours
: theme.tagColours as Partial<ThemeTagColours> | undefined,
defaultThemeTagColoursFor (baseTheme),
),
},
}
}
export const getClientCustomThemes = (): Record<string, CustomClientTheme> => {
const appearance = appearanceFromSettings ()
const customThemes = appearance.customThemes
if (customThemes && typeof customThemes === 'object')
{
return Object.entries (customThemes).reduce<Record<string, CustomClientTheme>> (
(themes, [key, rawTheme]) => {
const theme = normaliseCustomTheme (rawTheme)
if (theme != null)
themes[key] = theme
return themes
},
{ },
)
}
const legacyTagColours = appearance.tagColours
if (!(legacyTagColours) || Object.keys (legacyTagColours).length === 0)
return { }
const baseTheme = resolveThemeMode (appearance.theme ?? DEFAULT_USER_SETTINGS.theme)
return {
[LEGACY_MIGRATED_THEME_ID]: {
id: LEGACY_MIGRATED_THEME_ID,
name: themeCopyName (baseTheme),
builtin: false,
baseTheme,
tokens: {
...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS),
tagColours: normaliseThemeTagColours (
legacyTagColours,
defaultThemeTagColoursFor (baseTheme),
),
},
},
}
}
export const getClientActiveThemeId = (): ActiveThemeId => {
const appearance = appearanceFromSettings ()
const customThemes = getClientCustomThemes ()
if (
typeof appearance.activeThemeId === 'string'
&& (
appearance.activeThemeId === 'system'
|| isBuiltinThemeId (appearance.activeThemeId)
|| customThemes[appearance.activeThemeId] != null
)
)
return appearance.activeThemeId
if (
Object.keys (customThemes).length > 0
&& appearance.tagColours != null
&& Object.keys (appearance.tagColours).length > 0
)
return LEGACY_MIGRATED_THEME_ID
if (typeof appearance.theme === 'string' && (
appearance.theme === 'system'
|| appearance.theme === 'light'
|| appearance.theme === 'dark'
))
return appearance.theme
return DEFAULT_USER_SETTINGS.theme
}
export const getThemeById = (
themeId: ThemeId,
customThemes: Record<string, CustomClientTheme>,
): ClientTheme =>
isBuiltinThemeId (themeId)
? BUILTIN_THEMES[themeId]
: (customThemes[themeId] ?? BUILTIN_THEMES.light)
export const getResolvedThemeFromSelection = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
): ClientTheme =>
activeThemeId === 'system'
? BUILTIN_THEMES[resolveThemeMode ('system')]
: getThemeById (activeThemeId, customThemes)
export const deriveUserThemeModeFromSelection = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
): UserSettings['theme'] => {
if (activeThemeId === 'system')
return 'system'
return getThemeById (activeThemeId, customThemes).baseTheme
}
export const createCustomThemeFromBase = (
baseTheme: BuiltinThemeId,
tokens?: ThemeTokens,
): CustomClientTheme => {
const id = `custom:${ crypto.randomUUID () }` as ThemeId
return {
id,
name: themeCopyName (baseTheme),
builtin: false,
baseTheme,
tokens: tokens ?? {
...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS),
},
}
}
export const getClientThemeTagColours = (
activeThemeId: ActiveThemeId = getClientActiveThemeId (),
customThemes: Record<string, CustomClientTheme> = getClientCustomThemes (),
): ThemeTagColours => {
return {
...getResolvedThemeFromSelection (activeThemeId, customThemes).tokens.tagColours,
}
}
export const setClientAppearanceThemes = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
): void => {
updateClientSettings (settings => ({
...settings,
appearance: {
...(settings.appearance ?? { }),
activeThemeId,
customThemes,
theme: undefined,
tagColours: undefined,
},
}))
}
export const setClientThemeTagColours = (
tagColours: ThemeTagColours,
activeThemeId: ActiveThemeId = getClientActiveThemeId (),
customThemes: Record<string, CustomClientTheme> = getClientCustomThemes (),
): void => {
if (activeThemeId === 'system' || isBuiltinThemeId (activeThemeId))
return
setClientAppearanceThemes (
activeThemeId,
{
...customThemes,
[activeThemeId]: {
...customThemes[activeThemeId],
tokens: {
...customThemes[activeThemeId].tokens,
tagColours,
},
},
},
)
}
export const applyThemeMode = (theme: UserSettings['theme']): void => {
const root = document.documentElement
const resolvedTheme = resolveThemeMode (theme)
root.classList.toggle ('dark', resolvedTheme === 'dark')
root.style.colorScheme = resolvedTheme
}
export const applyThemeTagColours = (tagColours: ThemeTagColours): void => {
const root = document.documentElement
CATEGORIES.forEach (category => {
const colour = tagColours[category]
root.style.setProperty (`--tag-colour-${ category }`, colour)
root.style.setProperty (
`--tag-colour-${ category }-hover`,
mixHexColour (colour, '#ffffff', .18),
)
})
}
export const applyThemeTokens = (tokens: ThemeTokens): void => {
const root = document.documentElement
root.style.setProperty ('--background', tokens.background)
root.style.setProperty ('--foreground', tokens.foreground)
root.style.setProperty ('--card', tokens.card)
root.style.setProperty ('--card-foreground', tokens.cardForeground)
root.style.setProperty ('--popover', tokens.popover)
root.style.setProperty ('--popover-foreground', tokens.popoverForeground)
root.style.setProperty ('--primary', tokens.primary)
root.style.setProperty ('--primary-foreground', tokens.primaryForeground)
root.style.setProperty ('--secondary', tokens.secondary)
root.style.setProperty ('--secondary-foreground', tokens.secondaryForeground)
root.style.setProperty ('--muted', tokens.muted)
root.style.setProperty ('--muted-foreground', tokens.mutedForeground)
root.style.setProperty ('--accent', tokens.accent)
root.style.setProperty ('--accent-foreground', tokens.accentForeground)
root.style.setProperty ('--destructive', tokens.destructive)
root.style.setProperty ('--destructive-foreground', tokens.destructiveForeground)
root.style.setProperty ('--border', tokens.border)
root.style.setProperty ('--input', tokens.input)
root.style.setProperty ('--ring', tokens.ring)
root.style.setProperty ('--theme-link', tokens.link)
applyThemeTagColours (tokens.tagColours)
}
export const applyThemeSelection = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
): void => {
const theme = getResolvedThemeFromSelection (activeThemeId, customThemes)
applyThemeMode (theme.baseTheme)
applyThemeTokens (theme.tokens)
}
export const applyClientAppearance = (): void => {
applyThemeSelection (getClientActiveThemeId (), getClientCustomThemes ())
}
export const getClientListLimit = (
listKey: ClientListKey,
): ClientListLimit | null => {
const value = loadClientSettings ().lists?.[listKey]?.limit
return validListLimit (value)
}
export const getClientListOrder = <T extends string>(
listKey: ClientListKey,
): T | null => {
const value = loadClientSettings ().lists?.[listKey]?.order
return typeof value === 'string' ? value as T : null
}
export const setClientListSettings = (
listKey: ClientListKey,
{ limit, order }: { limit?: ClientListLimit
order?: string },
): void => {
updateClientSettings (settings => ({
...settings,
lists: {
...(settings.lists ?? { }),
[listKey]: {
...(settings.lists?.[listKey] ?? { }),
...(limit == null ? { } : { limit }),
...(order == null ? { } : { order }),
},
},
}))
}
const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => {
const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY)
return (
value === 'threeColumns'
|| value === 'tagsBottom'
|| value === 'commentsBottom'
) ? value : null
}
const legacyTheatreTagFlow = (): TheatreTagFlow | null => {
const value = localStorage.getItem (LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY)
return (value === 'vertical' || value === 'horizontal') ? value : null
}
const legacyGekanatorBackgroundMotion = (): GekanatorBackgroundMotionMode | null => {
const value = localStorage.getItem (LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY)
return (value === 'on' || value === 'calm' || value === 'off') ? value : null
}
export const getClientTheatreLayoutMode = (): TheatreLayoutMode =>
loadClientSettings ().theatre?.layoutMode
?? legacyTheatreLayoutMode ()
?? 'threeColumns'
export const setClientTheatreLayoutMode = (layoutMode: TheatreLayoutMode): void => {
updateClientSettings (settings => ({
...settings,
theatre: { ...(settings.theatre ?? { }), layoutMode } }))
}
export const getClientTheatreTagFlow = (): TheatreTagFlow =>
loadClientSettings ().theatre?.tagFlow
?? legacyTheatreTagFlow ()
?? 'vertical'
export const setClientTheatreTagFlow = (tagFlow: TheatreTagFlow): void => {
updateClientSettings (settings => ({
...settings,
theatre: { ...(settings.theatre ?? { }), tagFlow } }))
}
export const getClientGekanatorBackgroundMotion =
(): GekanatorBackgroundMotionMode =>
loadClientSettings ().gekanator?.backgroundMotion
?? legacyGekanatorBackgroundMotion ()
?? 'on'
export const setClientGekanatorBackgroundMotion = (
backgroundMotion: GekanatorBackgroundMotionMode,
): void => {
updateClientSettings (settings => ({
...settings,
gekanator: { ...(settings.gekanator ?? { }), backgroundMotion } }))
}
export const getClientPaneWidthPx = (
paneKey: string,
breakpoint: ClientPaneBreakpoint,
): number | null => {
const value = loadClientSettings ().panes?.[paneKey]?.widthPxByBreakpoint?.[breakpoint]
return typeof value === 'number' ? value : null
}
export const setClientPaneWidthPx = (
paneKey: string,
breakpoint: ClientPaneBreakpoint,
widthPx: number,
): void => {
updateClientSettings (settings => ({
...settings,
panes: {
...(settings.panes ?? { }),
[paneKey]: {
...(settings.panes?.[paneKey] ?? { }),
widthPxByBreakpoint: {
...(settings.panes?.[paneKey]?.widthPxByBreakpoint ?? { }),
[breakpoint]: widthPx,
},
},
},
}))
}
export const setClientPaneCollapsed = (
paneKey: string,
collapsed: boolean,
): void => {
updateClientSettings (settings => ({
...settings,
panes: {
...(settings.panes ?? { }),
[paneKey]: {
...(settings.panes?.[paneKey] ?? { }),
collapsed,
},
},
}))
}
+12 -1
ファイルの表示
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { cn, originalCreatedAtString, toDate } from '@/lib/utils'
import { cn, msToTime, originalCreatedAtString, toDate } from '@/lib/utils'
describe ('utils', () => {
it ('converts strings to dates and leaves date instances intact', () => {
@@ -26,3 +26,14 @@ describe ('utils', () => {
).toContain ('時刻不詳')
})
})
describe ('msToTime', () => {
it ('keeps milliseconds when present', () => {
expect (msToTime (60_500)).toBe ('1:00.500')
expect (msToTime (60_001)).toBe ('1:00.001')
})
it ('omits milliseconds when they are zero', () => {
expect (msToTime (60_000)).toBe ('1:00')
})
})
+16
ファイルの表示
@@ -71,6 +71,22 @@ export const originalCreatedAtString = (
}
export const msToTime = (ms: number): string => {
const totalS = Math.trunc (ms / 1_000)
const s = String (totalS % 60)
const min = String (Math.trunc (totalS / 60) % 60)
const h = Math.trunc (totalS / 3_600)
const remainderMs = ms % 1_000
const base =
(h > 0
? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }`
: `${ min }:${ s.padStart (2, '0') }`)
return remainderMs > 0 ? `${ base }.${ remainderMs.toString ().padStart (3, '0') }` : base
}
export const inputClass = (invalid?: boolean, className?: string): string =>
cn ('w-full rounded border p-2',
(invalid
+7 -9
ファイルの表示
@@ -24,6 +24,10 @@ import { recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery'
import { isQuestionHardFilteredAfterAnswers,
monthForCondition } from '@/lib/gekanatorQuestionFilters'
import { gekanatorKeys } from '@/lib/queryKeys'
import {
getClientGekanatorBackgroundMotion,
setClientGekanatorBackgroundMotion,
} from '@/lib/settings'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
@@ -185,7 +189,6 @@ const softenedAnswerWeight = .35
const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1'
const recentGamesStorageKey = 'gekanator:recent-games:v1'
const backgroundMotionStorageKey = 'gekanator:background-motion:v1'
const maxStoredRecentGames = 12
const specialOriginalMonthDayLabels: Record<string, string> = {
'1-1': '元日',
@@ -429,18 +432,13 @@ const storeRecentGameSummary = (
const loadBackgroundMotionMode = (): BackgroundMotionMode => {
const fallbackMode = 'on'
try
{
const raw = localStorage.getItem (backgroundMotionStorageKey)
if (raw === 'off' || raw === 'calm' || raw === 'on')
return raw
return fallbackMode
return getClientGekanatorBackgroundMotion ()
}
catch
{
return fallbackMode
return 'on'
}
}
@@ -3916,7 +3914,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
useEffect (() => {
try
{
localStorage.setItem (backgroundMotionStorageKey, backgroundMotionMode)
setClientGekanatorBackgroundMotion (backgroundMotionMode)
}
catch
{
+1 -1
ファイルの表示
@@ -79,7 +79,7 @@ const MaterialNewPage: FC = () => {
<FieldError messages={baseErrors}/>
{/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}>
<FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
+5 -1
ファイルの表示
@@ -91,9 +91,13 @@ const PostHistoryPage: FC = () => {
.filter (p => p.type !== 'removed')
.map (p => p.id)
.join (' ')
const duration =
change.videoMs.current == null
? null
: String (change.videoMs.current / 1_000)
const originalCreatedFrom = change.originalCreatedFrom.current
const originalCreatedBefore = change.originalCreatedBefore.current
await updatePost ({ id, title, tags, parentPostIds,
await updatePost ({ id, title, tags, duration, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ force: true })
+50 -5
ファイルの表示
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import PostList from '@/components/PostList'
import PrefetchLink from '@/components/PrefetchLink'
@@ -13,12 +13,23 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
getClientListLimit,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { fetchWikiPageByTitle } from '@/lib/wiki'
import type { FC } from 'react'
import type { WikiPage } from '@/types'
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const n = Number (value)
return n === 20 || n === 50 || n === 100 ? n : null
}
const PostListPage: FC = () => {
const containerRef = useRef<HTMLDivElement | null> (null)
@@ -26,6 +37,7 @@ const PostListPage: FC = () => {
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
const location = useLocation ()
const navigate = useNavigate ()
const query = new URLSearchParams (location.search)
const tagsQuery = query.get ('tags') ?? ''
const anyFlg = query.get ('match') === 'any'
@@ -33,13 +45,18 @@ const PostListPage: FC = () => {
const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery])
const tagsKey = tags.join (' ')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('postList')
?? DEFAULT_POST_LIST_LIMIT
)
const order = 'original_created_at:desc' as const
const keys = {
tags: tagsKey, match, page, limit,
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const
order } as const
const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
@@ -47,6 +64,17 @@ const PostListPage: FC = () => {
const cursor = ''
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('postList', { limit })
}, [limit])
const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => {
const params = new URLSearchParams (location.search)
params.set ('limit', String (next.limit ?? limit))
params.set ('page', '1')
navigate (`${ location.pathname }?${ params.toString () }`)
}
useLayoutEffect (() => {
scroll (0, 0)
@@ -89,7 +117,24 @@ const PostListPage: FC = () => {
}}/>
<MainArea>
<TabGroup>
<TabGroup
rightAddon={
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground"
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}>
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>
</label>
}>
<Tab name="広場">
{posts.length > 0
? (
+18
ファイルの表示
@@ -62,6 +62,24 @@ describe ('PostNewPage', () => {
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
})
it ('preserves duration while the video tag is temporarily removed', () => {
api.apiGet.mockResolvedValue ([])
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const tags = screen.getAllByRole ('textbox')[3]
fireEvent.change (tags, { target: { value: '動画' } })
fireEvent.change (screen.getByRole ('spinbutton'), { target: { value: '180.5' } })
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
})
it ('shows 422 validation errors for post fields', async () => {
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true)
+92 -82
ファイルの表示
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
@@ -25,7 +25,7 @@ import type { User } from '@/types'
type Props = { user: User | null }
type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds' | 'originalCreatedAt' | 'thumbnail'
'url' | 'title' | 'tags' | 'parentPostIds' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => {
@@ -40,17 +40,18 @@ const PostNewPage: FC<Props> = ({ user }) => {
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
const [duration, setDuration] = useState ('')
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false)
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
const [title, setTitle] = useState ('')
const [titleAutoFlg, setTitleAutoFlg] = useState (true)
const [titleLoading, setTitleLoading] = useState (false)
const [url, setURL] = useState ('')
const previousURLRef = useRef ('')
const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const handleSubmit = async () => {
clearValidationErrors ()
@@ -60,6 +61,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
formData.append ('url', url)
formData.append ('tags', tags)
formData.append ('parent_post_ids', parentPostIds)
if (videoFlg && duration !== '')
formData.append ('duration', duration)
if (thumbnailFile)
formData.append ('thumbnail', thumbnailFile)
if (originalCreatedFrom)
@@ -80,23 +83,17 @@ const PostNewPage: FC<Props> = ({ user }) => {
}
}
const handleURLBlur = () => {
if (!(url) || url === previousURLRef.current)
return
if (titleAutoFlg)
fetchTitle ()
if (thumbnailAutoFlg)
fetchThumbnail ()
previousURLRef.current = url
}
const fetchTitle = useCallback (async () => {
setTitle ('')
setTitleLoading (true)
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
setTitleLoading (false)
try
{
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
}
finally
{
setTitleLoading (false)
}
}, [url])
const fetchThumbnail = useCallback (async () => {
@@ -105,30 +102,26 @@ const PostNewPage: FC<Props> = ({ user }) => {
setThumbnailLoading (true)
if (thumbnailPreviewRef.current)
URL.revokeObjectURL (thumbnailPreviewRef.current)
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url }, responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
'thumbnail.png',
{ type: data.type || 'image/png' }))
setThumbnailLoading (false)
try
{
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url }, responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
'thumbnail.png',
{ type: data.type || 'image/png' }))
}
finally
{
setThumbnailLoading (false)
}
}, [url])
useEffect (() => {
thumbnailPreviewRef.current = thumbnailPreview
}, [thumbnailPreview])
useEffect (() => {
if (titleAutoFlg && url)
fetchTitle ()
}, [fetchTitle, titleAutoFlg, url])
useEffect (() => {
if (thumbnailAutoFlg && url)
fetchThumbnail ()
}, [fetchThumbnail, thumbnailAutoFlg, url])
if (!(editable))
return <Forbidden/>
@@ -150,59 +143,62 @@ const PostNewPage: FC<Props> = ({ user }) => {
onChange={e => setURL (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
onBlur={handleURLBlur}/>)}
className={inputClass (invalid)}/>)}
</FormField>
{/* タイトル */}
<FormField
checkBox={{
label: '自動',
checked: titleAutoFlg,
onChange: ev => setTitleAutoFlg (ev.target.checked)}}
label="タイトル"
messages={fieldErrors.title}>
<FormField label="タイトル" messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleAutoFlg}/>)}
<div className="space-y-2">
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleLoading}/>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>
</div>
</div>)}
</FormField>
{/* サムネール */}
<FormField
checkBox={{
label: '自動',
checked: thumbnailAutoFlg,
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}
label="サムネール"
messages={fieldErrors.thumbnail}>
<FormField label="サムネール" messages={fieldErrors.thumbnail}>
{({ describedBy, invalid }) => (
<>
{thumbnailAutoFlg
? (thumbnailLoading
? <p className="text-gray-500 text-sm">Loading...</p>
: !(thumbnailPreview) && (
<p className="text-gray-500 text-sm">
URL
</p>))
: (
<input type="file"
accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>)}
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>
</div>
{thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)}
<input type="file"
accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
{thumbnailPreview && (
<img src={thumbnailPreview}
alt="preview"
@@ -233,6 +229,20 @@ const PostNewPage: FC<Props> = ({ user }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{(videoFlg &&
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
value={duration}
onChange={e => setDuration (e.target.value)}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>)}
{/* 送信 */}
<Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
+79 -4
ファイルの表示
@@ -16,6 +16,14 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_POST_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
@@ -31,6 +39,31 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
qs.set (k, t)
}
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const n = Number (value)
return n === 20 || n === 50 || n === 100 ? n : null
}
const parseOrder = (value: string | null): FetchPostsOrder | null => {
switch (value)
{
case 'title:asc':
case 'title:desc':
case 'url:asc':
case 'url:desc':
case 'original_created_at:asc':
case 'original_created_at:desc':
case 'created_at:asc':
case 'created_at:desc':
case 'updated_at:asc':
case 'updated_at:desc':
return value
default:
return null
}
}
const PostSearchPage: FC = () => {
const location = useLocation ()
@@ -41,7 +74,11 @@ const PostSearchPage: FC = () => {
[location.search])
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('postSearch')
?? DEFAULT_POST_LIST_LIMIT
)
const qURL = query.get ('url') ?? ''
const qTitle = query.get ('title') ?? ''
@@ -53,7 +90,11 @@ const PostSearchPage: FC = () => {
const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') ?? ''
const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
const order = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchPostsOrder> ('postSearch')
?? DEFAULT_POST_LIST_ORDER
)
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
@@ -83,6 +124,10 @@ const PostSearchPage: FC = () => {
const results = data?.posts ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('postSearch', { limit, order })
}, [limit, order])
useEffect (() => {
setURL (qURL ?? '')
setTitle (qTitle ?? '')
@@ -113,6 +158,16 @@ const PostSearchPage: FC = () => {
qs.set ('match', matchType)
qs.set ('page', '1')
qs.set ('order', order)
qs.set ('limit', String (limit))
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const updateListQuery = (next: { limit?: 20 | 50 | 100
order?: FetchPostsOrder }) => {
const qs = new URLSearchParams (location.search)
qs.set ('limit', String (next.limit ?? limit))
qs.set ('order', next.order ?? order)
qs.set ('page', '1')
navigate (`${ location.pathname }?${ qs.toString () }`)
}
@@ -134,7 +189,24 @@ const PostSearchPage: FC = () => {
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground"
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}>
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>
</label>
</div>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
@@ -161,6 +233,7 @@ const PostSearchPage: FC = () => {
<FormField label="タグ">
{() => (
<TagInput
includeNico={true}
value={tagsStr}
setValue={setTagsStr}/>)}
</FormField>
@@ -296,7 +369,9 @@ const PostSearchPage: FC = () => {
</thead>
<tbody>
{results.map (row => (
<tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
<tr
key={row.id}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
<motion.div
+75 -3
ファイルの表示
@@ -14,6 +14,14 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { tagsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_TAG_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { fetchTags } from '@/lib/tags'
import { dateString, inputClass } from '@/lib/utils'
@@ -35,6 +43,31 @@ const boolFromQuery = (value: string | null): boolean =>
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const n = Number (value)
return n === 20 || n === 50 || n === 100 ? n : null
}
const parseOrder = (value: string | null): FetchTagsOrder | null => {
switch (value)
{
case 'name:asc':
case 'name:desc':
case 'category:asc':
case 'category:desc':
case 'post_count:asc':
case 'post_count:desc':
case 'created_at:asc':
case 'created_at:desc':
case 'updated_at:asc':
case 'updated_at:desc':
return value
default:
return null
}
}
const TagListPage: FC = () => {
const location = useLocation ()
@@ -44,7 +77,11 @@ const TagListPage: FC = () => {
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('tagList')
?? DEFAULT_POST_LIST_LIMIT
)
const qName = query.get ('name') ?? ''
const qCategory = (query.get ('category') || null) as Category | null
@@ -58,7 +95,11 @@ const TagListPage: FC = () => {
const qDeprecated = query.has ('deprecated')
? boolFromQuery (query.get ('deprecated'))
: null
const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder
const order = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchTagsOrder> ('tagList')
?? DEFAULT_TAG_LIST_ORDER
)
const [name, setName] = useState ('')
const [category, setCategory] = useState<Category | null> (null)
@@ -88,6 +129,10 @@ const TagListPage: FC = () => {
const results = data?.tags ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('tagList', { limit, order })
}, [limit, order])
useEffect (() => {
setName (qName)
setCategory (qCategory)
@@ -121,10 +166,20 @@ const TagListPage: FC = () => {
qs.set ('deprecated', deprecated ? '1' : '0')
qs.set ('page', '1')
qs.set ('order', order)
qs.set ('limit', String (limit))
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const updateListQuery = (next: { limit?: 20 | 50 | 100
order?: FetchTagsOrder }) => {
const qs = new URLSearchParams (location.search)
qs.set ('limit', String (next.limit ?? limit))
qs.set ('order', next.order ?? order)
qs.set ('page', '1')
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const defaultDirection = { name: 'asc',
category: 'asc',
post_count: 'desc',
@@ -138,7 +193,24 @@ const TagListPage: FC = () => {
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<select
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground">
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>
</label>
</div>
<form onSubmit={handleSearch} className="space-y-2">
{/* 名前 */}
+8 -4
ファイルの表示
@@ -88,6 +88,10 @@ const weights = buildTheatrePostSelectionWeights ({
tags: [],
}],
})
const watchingUsers = [
{ id: 1, name: 'tester' },
{ id: 2, name: 'another' },
]
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
renderWithProviders (
@@ -136,11 +140,11 @@ const mockDefaultApi = () => {
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers: [{ id: 1, name: 'tester' }],
watchingUsers,
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
watchingUsersCount: watchingUsers.length,
voted: false,
},
}))
@@ -150,11 +154,11 @@ const mockDefaultApi = () => {
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 2_000,
watchingUsers: [{ id: 1, name: 'tester' }],
watchingUsers,
skipVote: {
votesCount: 1,
requiredCount: 2,
watchingUsersCount: 1,
watchingUsersCount: watchingUsers.length,
voted: true,
},
}))
+11 -20
ファイルの表示
@@ -15,6 +15,12 @@ import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { fetchPost } from '@/lib/posts'
import {
getClientTheatreLayoutMode,
getClientTheatreTagFlow,
setClientTheatreLayoutMode,
setClientTheatreTagFlow,
} from '@/lib/settings'
import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -50,9 +56,6 @@ const INITIAL_THEATRE_INFO: TheatreInfo =
const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
{ tagPenalties: [], lightestPosts: [], heaviestPosts: [] }
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列',
tagsBottom: '2 列 A 型',
@@ -228,32 +231,20 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const [post, setPost] = useState<Post | null> (null)
const [videoLength, setVideoLength] = useState (0)
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => {
const stored = localStorage.getItem (LAYOUT_STORAGE_KEY)
return (
((['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[])
.includes (stored as TheatreLayoutMode))
? (stored as TheatreLayoutMode)
: 'threeColumns')
})
const [tagFlow, setTagFlow] = useState<TagFlow> (() => {
const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY)
return (
(['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow)
? (stored as TagFlow)
: 'vertical')
})
const [layoutMode, setLayoutMode] =
useState<TheatreLayoutMode> (() => getClientTheatreLayoutMode ())
const [tagFlow, setTagFlow] = useState<TagFlow> (() => getClientTheatreTagFlow ())
const { fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TheatreCommentField> ()
const changeLayoutMode = (mode: TheatreLayoutMode) => {
setLayoutMode (mode)
localStorage.setItem (LAYOUT_STORAGE_KEY, mode)
setClientTheatreLayoutMode (mode)
}
const changeTagFlow = (flow: TagFlow) => {
setTagFlow (flow)
localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow)
setClientTheatreTagFlow (flow)
}
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
+765 -69
ファイルの表示
@@ -1,43 +1,553 @@
import type { FC } from 'react'
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink'
import InheritDialogue from '@/components/users/InheritDialogue'
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import {
applyThemeSelection,
BUILTIN_THEMES,
createCustomThemeFromBase,
defaultThemeTagColoursFor,
DEFAULT_USER_SETTINGS,
deriveUserThemeModeFromSelection,
fetchUserSettings,
hasStoredClientThemeSelection,
getClientActiveThemeId,
getClientCustomThemes,
getResolvedThemeFromSelection,
setClientAppearanceThemes,
updateUserSettings,
} from '@/lib/settings'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { User } from '@/types'
import type { Category, Tag, User } from '@/types'
import type {
ActiveThemeId,
ClientTheme,
CustomClientTheme,
ThemeTagColours,
UserSettings,
} from '@/lib/settings'
type Props = { user: User | null
setUser: React.Dispatch<React.SetStateAction<User | null>> }
type Props = {
user: User | null
setUser: Dispatch<SetStateAction<User | null>>
}
type UserFormField = 'name'
type SettingsFormField = 'theme'
type SettingsTab = 'account' | 'theme' | 'keyboard'
type TabSpec = {
id: SettingsTab
label: string }
type SharedSectionProps = {
user: User
name: string
onNameChange: (value: string) => void
onInheritOpen: () => void
onUserCodeOpen: () => void
onUserSubmit: () => void
userBaseErrors: string[]
userFieldErrors: Partial<Record<UserFormField, string[]>>
draftActiveThemeId: ActiveThemeId
draftCustomThemes: Record<string, CustomClientTheme>
selectedTheme: ClientTheme
draftTagColours: ThemeTagColours
onThemeChange: (themeId: ActiveThemeId) => void
onTagColourChange: (category: Category, colour: string) => void
onResetAllTagColours: () => void
onResetTagColour: (category: Category) => void
onThemeSubmit: () => void
settingsBaseErrors: string[]
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>>
}
const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' },
{ id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' },
]
const sectionClassName =
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
const themeLabel: Record<UserSettings['theme'], string> = {
system: 'システム設定に従う',
light: 'ライト・モード',
dark: 'ダーク・モード',
}
const builtinThemeLabel: Record<'light' | 'dark', string> = {
light: 'ライト・モード',
dark: 'ダーク・モード',
}
const keyboardShortcutRows = [
{
action: '投稿詳細を開く',
key: 'Enter',
scope: '一覧・検索結果',
conflict: 'なし',
},
{
action: '候補タグを選ぶ',
key: '↑ / ↓ / Enter',
scope: 'タグ入力',
conflict: 'なし',
},
{
action: '候補タグを閉じる',
key: 'Escape',
scope: 'タグ入力',
conflict: 'なし',
},
]
const previewTag = (
id: number,
name: string,
category: Category,
postCount: number,
hasWiki: boolean,
options?: { hasDeerjikists?: boolean },
): Tag => ({
id,
name,
category,
deprecatedAt: null,
aliases: [],
parents: [],
postCount,
createdAt: '',
updatedAt: '',
hasWiki,
materialId: null,
hasDeerjikists: options?.hasDeerjikists ?? false,
children: [],
})
const previewTags: Record<Category, Tag> = {
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
meme: previewTag (2, 'ミーム', 'meme', 34, true),
character: previewTag (3, 'キャラクター', 'character', 56, false),
general: previewTag (4, '一般', 'general', 78, true),
material: previewTag (5, '素材', 'material', 90, false),
meta: previewTag (6, 'メタ', 'meta', 21, true),
nico: previewTag (7, 'nico:sm9', 'nico', 43, false),
}
const parseTab = (search: string): SettingsTab => {
const tab = new URLSearchParams (search).get ('tab')
return tabs.some (candidate => candidate.id === tab)
? tab as SettingsTab
: 'account'
}
const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }`
const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }`
const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
Object.fromEntries (
CATEGORIES.flatMap (category => [
[`--tag-colour-${ category }`, tagColours[category]],
[`--tag-colour-${ category }-hover`, tagColours[category]],
]),
) as CSSProperties
const themeLabelById = (
themeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
): string => {
if (themeId === 'system')
return themeLabel.system
if (themeId === 'light' || themeId === 'dark')
return builtinThemeLabel[themeId]
return (customThemes[themeId] ?? BUILTIN_THEMES.light).name
}
const AccountSection: FC<SharedSectionProps> = ({
name,
onInheritOpen,
onNameChange,
onUserCodeOpen,
onUserSubmit,
user,
userBaseErrors,
userFieldErrors,
}) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={userBaseErrors}/>
<FormField label="表示名" messages={userFieldErrors.name}>
{({ describedBy, invalid }) => (
<>
<input
type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name}
placeholder="名もなきニジラー"
onChange={ev => onNameChange (ev.target.value)}/>
{!(user.name) && (
<p className="mt-1 text-sm text-red-500">
30 !!!!
</p>)}
</>)}
</FormField>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={onUserSubmit}>
</Button>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={onUserCodeOpen}
className="bg-gray-600 text-white"
disabled={!(user)}>
</Button>
<Button
type="button"
onClick={onInheritOpen}
className="bg-red-600 text-white"
disabled={!(user)}>
</Button>
</div>
</div>
</section>)
const ThemeSection: FC<SharedSectionProps> = ({
draftActiveThemeId,
draftCustomThemes,
selectedTheme,
draftTagColours,
onResetAllTagColours,
onResetTagColour,
onTagColourChange,
onThemeChange,
onThemeSubmit,
settingsBaseErrors,
settingsFieldErrors,
}) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={settingsBaseErrors}/>
<FormField label="基本テーマ" messages={settingsFieldErrors.theme}>
{() => (
<select
className={inputClass (Boolean (settingsFieldErrors.theme?.length))}
value={draftActiveThemeId}
onChange={ev => onThemeChange (ev.target.value as ActiveThemeId)}>
<option value="system">{themeLabel.system}</option>
<option value="light">{builtinThemeLabel.light}</option>
<option value="dark">{builtinThemeLabel.dark}</option>
{Object.values (draftCustomThemes).length > 0 && (
<optgroup label="カスタムテーマ">
{Object.values (draftCustomThemes).map (theme => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>))}
</optgroup>)}
</select>)}
</FormField>
<div className="space-y-2 rounded-lg border border-dashed border-border/70 p-3 text-sm">
<p>
base
</p>
<p className="text-muted-foreground">
backend system / light / dark mode
browser-local
</p>
<p className="text-muted-foreground">
themes
</p>
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium"></h3>
<p className="text-sm text-muted-foreground">
{selectedTheme.builtin || draftActiveThemeId === 'system'
? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.'
: `現在の編輯対象: ${ selectedTheme.name }`}
</p>
</div>
<Button
type="button"
variant="outline"
onClick={onResetAllTagColours}
disabled={selectedTheme.builtin || draftActiveThemeId === 'system'}>
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{CATEGORIES.map (category => {
const resolvedDefaults =
defaultThemeTagColoursFor (selectedTheme.baseTheme)
const isDefault =
draftTagColours[category] === resolvedDefaults[category]
return (
<div
key={category}
className="space-y-2 rounded-lg border border-border/70 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm">{CATEGORY_NAMES[category]}</span>
<input
type="color"
value={draftTagColours[category]}
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
className="h-auto px-0 py-0 text-xs"
onClick={() => onResetTagColour (category)}
disabled={
isDefault
|| selectedTheme.builtin
|| draftActiveThemeId === 'system'
}>
</Button>
</div>
</div>)
})}
</div>
</div>
<div className="space-y-2 rounded-lg border border-border bg-background p-3
text-foreground shadow-sm transition-colors">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">
: {themeLabelById (draftActiveThemeId, draftCustomThemes)}
</p>
<div className="flex flex-wrap gap-3" style={themePreviewStyle (draftTagColours)}>
{CATEGORIES.map (category => (
<TagLink
key={category}
tag={previewTags[category]}
linkFlg={false}
truncateOnMobile
withWiki={false}/>))}
</div>
</div>
<div className="space-y-2 rounded-lg border border-dashed border-border/70 p-3 text-sm">
<p className="font-medium"> backend </p>
<p>
auto_fetch_titleauto_fetch_thumbnailwiki_editor_mode backend
</p>
</div>
<Button type="button" onClick={onThemeSubmit}>
</Button>
</section>)
const KeyboardSection: FC = () => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
action / key / scope / conflict
key bind
</p>
<div className="overflow-x-auto">
<table className="w-full min-w-[28rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{keyboardShortcutRows.map (row => (
<tr key={row.action} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.action}</td>
<td className="p-2 font-mono">{row.key}</td>
<td className="p-2">{row.scope}</td>
<td className="p-2">{row.conflict}</td>
</tr>))}
</tbody>
</table>
</div>
</section>)
const SettingPage: FC<Props> = ({ user, setUser }) => {
const [name, setName] = useState ('')
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const [inheritVsbl, setInheritVsbl] = useState (false)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<UserFormField> ()
const location = useLocation ()
const navigate = useNavigate ()
const handleSubmit = async () => {
const [inheritVsbl, setInheritVsbl] = useState (false)
const [loading, setLoading] = useState (true)
const [name, setName] = useState ('')
const [settingsError, setSettingsError] = useState<string | null> (null)
const [, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
const [savedActiveThemeId, setSavedActiveThemeId] =
useState<ActiveThemeId> (getClientActiveThemeId ())
const [savedCustomThemes, setSavedCustomThemes] =
useState<Record<string, CustomClientTheme>> (getClientCustomThemes ())
const [draftActiveThemeId, setDraftActiveThemeId] =
useState<ActiveThemeId> (getClientActiveThemeId ())
const [draftCustomThemes, setDraftCustomThemes] =
useState<Record<string, CustomClientTheme>> (getClientCustomThemes ())
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const {
baseErrors: userBaseErrors,
fieldErrors: userFieldErrors,
clearValidationErrors: clearUserValidationErrors,
applyValidationError: applyUserValidationError,
} = useValidationErrors<UserFormField> ()
const {
baseErrors: settingsBaseErrors,
fieldErrors: settingsFieldErrors,
clearValidationErrors: clearSettingsValidationErrors,
applyValidationError: applySettingsValidationError,
} = useValidationErrors<SettingsFormField> ()
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
const selectedTheme = useMemo<ClientTheme> (
() => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes),
[draftActiveThemeId, draftCustomThemes],
)
const draftTagColours = selectedTheme.tokens.tagColours
const setActiveTab = (tab: SettingsTab) => {
const params = new URLSearchParams (location.search)
params.set ('tab', tab)
navigate (
`${ location.pathname }?${ params.toString () }`,
{ replace: true },
)
}
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (() => ({
account: Boolean (userFieldErrors.name?.length),
theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: false,
}), [settingsFieldErrors.theme, userFieldErrors.name])
const applyDraftThemeSelection = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
) => {
setDraftActiveThemeId (activeThemeId)
setDraftCustomThemes (customThemes)
setDraftSettings (current => ({
...current,
theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes),
}))
applyThemeSelection (activeThemeId, customThemes)
}
const ensureEditableCustomTheme = (): {
activeThemeId: ActiveThemeId
customThemes: Record<string, CustomClientTheme>
} => {
if (draftActiveThemeId !== 'system' && draftCustomThemes[draftActiveThemeId] != null)
{
return {
activeThemeId: draftActiveThemeId,
customThemes: draftCustomThemes,
}
}
const customTheme = createCustomThemeFromBase (
selectedTheme.baseTheme,
{
...selectedTheme.tokens,
tagColours: { ...selectedTheme.tokens.tagColours },
},
)
const nextCustomThemes = {
...draftCustomThemes,
[customTheme.id]: customTheme,
}
return {
activeThemeId: customTheme.id,
customThemes: nextCustomThemes,
}
}
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const currentIndex = tabs.findIndex (tab => tab.id === activeTab)
switch (event.key)
{
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault ()
setActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
break
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault ()
setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
break
case 'Home':
event.preventDefault ()
setActiveTab (tabs[0].id)
break
case 'End':
event.preventDefault ()
setActiveTab (tabs[tabs.length - 1].id)
break
}
}
const handleUserSubmit = async () => {
if (!(user))
return
clearValidationErrors ()
clearUserValidationErrors ()
const formData = new FormData
formData.append ('name', name)
@@ -47,13 +557,35 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const data = await apiPut<User> (
`/users/${ user.id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
setUser (user => ({ ...user, ...data }))
toast ({ title: '設定を更新しました.' })
setUser (currentUser => ({ ...currentUser, ...data }))
toast ({ title: '表示名を更新しました.' })
}
catch (e)
catch (submitError)
{
applyValidationError (e)
toast ({ title: 'しっぱい……' })
applyUserValidationError (submitError)
toast ({ title: '表示名を更新できませんでした.' })
}
}
const handleThemeSubmit = async () => {
clearSettingsValidationErrors ()
try
{
const themeMode =
deriveUserThemeModeFromSelection (draftActiveThemeId, draftCustomThemes)
const data = await updateUserSettings ({ theme: themeMode })
setDraftSettings ({ ...data, theme: themeMode })
setSavedActiveThemeId (draftActiveThemeId)
setSavedCustomThemes (draftCustomThemes)
setClientAppearanceThemes (draftActiveThemeId, draftCustomThemes)
applyThemeSelection (draftActiveThemeId, draftCustomThemes)
toast ({ title: 'テーマ設定を保存しました.' })
}
catch (submitError)
{
applySettingsValidationError (submitError)
toast ({ title: 'テーマ設定を保存できませんでした.' })
}
}
@@ -64,6 +596,152 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setName (user.name ?? '')
}, [user])
useEffect (() => {
if (!(user))
return
let cancelled = false
void (async () => {
setLoading (true)
try
{
const data = await fetchUserSettings ()
if (cancelled)
return
const customThemes = getClientCustomThemes ()
const activeThemeId = hasStoredClientThemeSelection ()
? getClientActiveThemeId ()
: data.theme
const themeMode = deriveUserThemeModeFromSelection (activeThemeId, customThemes)
setDraftSettings ({ ...data, theme: themeMode })
setSavedActiveThemeId (activeThemeId)
setSavedCustomThemes (customThemes)
setDraftActiveThemeId (activeThemeId)
setDraftCustomThemes (customThemes)
setSettingsError (null)
}
catch
{
if (cancelled)
return
setSettingsError ('設定の読込みに失敗しました.')
}
finally
{
if (!(cancelled))
setLoading (false)
}
}) ()
return () => {
cancelled = true
}
}, [user])
useEffect (() => {
if (userFieldErrors.name?.length)
setActiveTab ('account')
}, [location.pathname, location.search, userFieldErrors.name])
useEffect (() => {
if (settingsFieldErrors.theme?.length)
setActiveTab ('theme')
}, [location.pathname, location.search, settingsFieldErrors.theme])
useEffect (() => {
applyThemeSelection (draftActiveThemeId, draftCustomThemes)
return () => {
applyThemeSelection (savedActiveThemeId, savedCustomThemes)
}
}, [draftActiveThemeId, draftCustomThemes, savedActiveThemeId, savedCustomThemes])
const sectionProps: SharedSectionProps | null = user ? {
user,
name,
onNameChange: setName,
onInheritOpen: () => setInheritVsbl (true),
onUserCodeOpen: () => setUserCodeVsbl (true),
onUserSubmit: handleUserSubmit,
userBaseErrors,
userFieldErrors,
draftActiveThemeId,
draftCustomThemes,
selectedTheme,
draftTagColours,
onThemeChange: themeId => {
applyDraftThemeSelection (themeId, draftCustomThemes)
},
onTagColourChange: (category, colour) => {
const editable = ensureEditableCustomTheme ()
const editableTheme = editable.customThemes[editable.activeThemeId]
const nextCustomThemes = {
...editable.customThemes,
[editable.activeThemeId]: {
...editableTheme,
tokens: {
...editableTheme.tokens,
tagColours: {
...editableTheme.tokens.tagColours,
[category]: colour,
},
},
},
}
applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes)
},
onResetAllTagColours: () => {
if (selectedTheme.builtin || draftActiveThemeId === 'system')
return
applyDraftThemeSelection (
draftActiveThemeId,
{
...draftCustomThemes,
[draftActiveThemeId]: {
...draftCustomThemes[draftActiveThemeId],
tokens: {
...draftCustomThemes[draftActiveThemeId].tokens,
tagColours: {
...defaultThemeTagColoursFor (selectedTheme.baseTheme),
},
},
},
},
)
},
onResetTagColour: category => {
if (selectedTheme.builtin || draftActiveThemeId === 'system')
return
applyDraftThemeSelection (
draftActiveThemeId,
{
...draftCustomThemes,
[draftActiveThemeId]: {
...draftCustomThemes[draftActiveThemeId],
tokens: {
...draftCustomThemes[draftActiveThemeId].tokens,
tagColours: {
...draftCustomThemes[draftActiveThemeId].tokens.tagColours,
[category]:
defaultThemeTagColoursFor (selectedTheme.baseTheme)[category],
},
},
},
},
)
},
onThemeSubmit: handleThemeSubmit,
settingsBaseErrors,
settingsFieldErrors,
} : null
return (
<MainArea>
<Helmet>
@@ -71,62 +749,80 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<title> | {SITE_TITLE}</title>
</Helmet>
<Form>
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
<PageTitle></PageTitle>
{user ? (
<FieldError messages={settingsError ? [settingsError] : []}/>
{loading ? 'Loading...' : (
<>
<FieldError messages={baseErrors}/>
{sectionProps && (
<>
<AccountSection {...sectionProps}/>
<ThemeSection {...sectionProps}/>
<KeyboardSection/>
</>)}
</>)}
</div>
{/* 名前 */}
<FormField label="表示名" messages={fieldErrors.name}>
{({ describedBy, invalid }) => (
<>
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name}
placeholder="名もなきニジラー"
onChange={ev => setName (ev.target.value)}/>
{(user && !(user.name)) && (
<p className="mt-1 text-sm text-red-500">
30 !!!!
</p>)}
</>)}
</FormField>
<div className="hidden md:flex md:justify-center md:px-4">
<div className="grid w-full max-w-[58rem] grid-cols-[12rem_minmax(0,1fr)] gap-6">
<div
role="tablist"
aria-label="設定区分"
aria-orientation="vertical"
className="sticky top-4 flex h-fit flex-col gap-2">
{tabs.map (tab => (
<button
key={tab.id}
id={tabButtonId (tab.id)}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={tabPanelId (tab.id)}
tabIndex={activeTab === tab.id ? 0 : -1}
className={cn (
'rounded-xl border px-4 py-3 text-left text-sm font-medium',
activeTab === tab.id
? ['border-slate-900 bg-slate-900 text-white',
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
: ['border-slate-300 bg-white text-slate-700',
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'],
)}
onClick={() => setActiveTab (tab.id)}
onKeyDown={handleTabKeyDown}>
{tab.label}
{settingsTabErrors[tab.id] ? ' ⚠' : ''}
</button>))}
</div>
{/* 送信 */}
<Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
</Button>
<div className="w-full max-w-xl justify-self-center space-y-4">
<PageTitle></PageTitle>
<FieldError messages={settingsError ? [settingsError] : []}/>
{loading ? 'Loading...' : (
<div
id={tabPanelId (activeTab)}
role="tabpanel"
aria-labelledby={tabButtonId (activeTab)}>
{sectionProps && (
<>
{activeTab === 'account' && <AccountSection {...sectionProps}/>}
{activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
{activeTab === 'keyboard' && <KeyboardSection/>}
</>)}
</div>)}
</div>
</div>
</div>
{/* 引継ぎ */}
<div>
<Label></Label>
<Button onClick={() => setUserCodeVsbl (true)}
className="px-4 py-2 bg-gray-600 text-white rounded disabled:bg-gray-400"
disabled={!(user)}>
</Button>
<Button onClick={() => setInheritVsbl (true)}
className="ml-2 px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400"
disabled={!(user)}>
</Button>
</div>
</>) : 'Loading...'}
</Form>
<UserCodeDialogue
visible={userCodeVsbl}
onVisibleChange={setUserCodeVsbl}
user={user}
setUser={setUser}/>
<UserCodeDialogue visible={userCodeVsbl}
onVisibleChange={setUserCodeVsbl}
user={user}
setUser={setUser}/>
<InheritDialogue visible={inheritVsbl}
onVisibleChange={setInheritVsbl}
setUser={setUser}/>
<InheritDialogue
visible={inheritVsbl}
onVisibleChange={setInheritVsbl}
setUser={setUser}/>
</MainArea>)
}
+6
ファイルの表示
@@ -24,6 +24,11 @@ import type { FC } from 'react'
import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
// `wiki_editor_mode` is still kept in backend settings, but the common
// settings screen does not expose it at present. Until that UI returns, the
// editor stays on the fixed split view here.
type Props = { user: User | null }
@@ -114,6 +119,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
config={{ view: FIXED_WIKI_EDITOR_VIEW }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)}
</FormField>
+6
ファイルの表示
@@ -22,6 +22,11 @@ import 'react-markdown-editor-lite/lib/index.css'
import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
// `wiki_editor_mode` is still kept in backend settings, but the common
// settings screen does not expose it at present. Until that UI returns, the
// editor stays on the fixed split view here.
type Props = { user: User | null }
@@ -92,6 +97,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
config={{ view: FIXED_WIKI_EDITOR_VIEW }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)}
</FormField>
+5 -2
ファイルの表示
@@ -1,6 +1,6 @@
import type { Material,
Post,
Tag,
TagWithSections,
Theatre,
TheatreComment,
TheatreInfo,
@@ -9,7 +9,7 @@ import type { Material,
User,
WikiPage } from '@/types'
export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
export const buildTag = (overrides: Partial<TagWithSections> = {}): TagWithSections => ({
id: 1,
name: 'テストタグ',
category: 'general',
@@ -23,6 +23,8 @@ export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
materialId: null,
hasDeerjikists: false,
matchedAlias: null,
sections: [],
children: [],
...overrides,
})
@@ -33,6 +35,7 @@ export const buildPost = (overrides: Partial<Post> = {}): Post => ({
title: 'テスト投稿',
thumbnail: 'https://example.com/thumb.jpg',
thumbnailBase: null,
videoMs: null,
tags: [buildTag ()],
parentPosts: [],
childPosts: [],
+7 -1
ファイルの表示
@@ -288,11 +288,12 @@ export type Post = {
title: string | null
thumbnail: string | null
thumbnailBase: string | null
videoMs: number | null
postSimilarityEdges?: {
targetPostId: number
cos: number
}[]
tags: Tag[]
tags: TagWithSections[]
parentPosts?: Post[]
childPosts?: Post[]
siblingPosts?: Record<`${ number }`, Post[]>
@@ -320,6 +321,7 @@ export type PostVersion = {
url: { current: string; prev: string | null }
thumbnail: { current: string | null; prev: string | null }
thumbnailBase: { current: string | null; prev: string | null }
videoMs: { current: number | null; prev: number | null }
tags: { name: string
type: 'context' | 'added' | 'removed' }[]
parentPosts: { id: number
@@ -369,6 +371,10 @@ export type TagVersion = {
createdAt: string
createdByUser: { id: number; name: string | null } | null }
export type TagWithSections = Omit<Tag, 'children'> & { sections: { beginMs: number
endMs: number | null }[]
children: TagWithSections[] }
export type Theatre = {
id: number
name: string | null
+1
ファイルの表示
@@ -8,6 +8,7 @@ import { DARK_COLOUR_SHADE,
const colours = Object.values (TAG_COLOUR)
export default {
darkMode: 'class',
content: ['./src/**/*.{html,js,ts,jsx,tsx,mdx}'],
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),