コミットを比較

...

8 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 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
みてるぞ 5657fa7e7b “開発モード” の表示 (#389) (#393)
Reviewed-on: #393
2026-07-02 12:47:19 +09:00
みてるぞ 19a185d5b5 上映会タグ表示バグ修正 (#360) (#384)
まだ画面チェックしてゐないのでマージ禁止

Reviewed-on: #384
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-02 01:37:49 +09:00
みてるぞ acd062f289 タグ循環の禁止 (#332) (#382)
Reviewed-on: #382
2026-06-30 01:20:55 +09:00
66個のファイルの変更3136行の追加264行の削除
+9 -2
ファイルの表示
@@ -106,6 +106,10 @@ npm run preview
- Explain risks directly. - Explain risks directly.
- Prefer single quotes for strings unless interpolation or escaping makes - Prefer single quotes for strings unless interpolation or escaping makes
double quotes better. 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: never put a space before method-call parentheses.
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。 - Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
- Ruby: never put a line break immediately before `)`. - Ruby: never put a line break immediately before `)`.
@@ -255,8 +259,11 @@ const value =
`!==` over negating a comparison like `!(a === b)`. `!==` over negating a comparison like `!(a === b)`.
- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for - In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for
simple unit-step counter updates. simple unit-step counter updates.
- For user-facing Japanese text, prefer modern kana usage and natural current - For user-facing Japanese text, follow the 1986 Cabinet Notice
phrasing over historical spellings or awkward literal wording. 《現代仮名遣い》 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 `...`. - For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
### Frontend TSX style ### Frontend TSX style
+5
ファイルの表示
@@ -25,6 +25,7 @@ class PostVersionsController < ApplicationController
SQL SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url', .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.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_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before') 'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
@@ -74,6 +75,10 @@ class PostVersionsController < ApplicationController
current: row.thumbnail_base, current: row.thumbnail_base,
prev: row.attributes['prev_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), tags: build_version_tags(cur_tags, prev_tags),
original_created_from: { original_created_from: {
current: row.original_created_from&.iso8601, current: row.original_created_from&.iso8601,
+134 -25
ファイルの表示
@@ -1,6 +1,10 @@
class PostsController < ApplicationController class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
class VideoMsParseError < ArgumentError
;
end
def index def index
url = params[:url].presence url = params[:url].presence
title = params[:title].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") .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")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:uploaded_user, :parents, :children, .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 .with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -97,7 +103,9 @@ class PostsController < ApplicationController
def random def random
post = filtered_posts.preload(:uploaded_user, :parents, :children, 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 .with_attached_thumbnail
.order('RAND()') .order('RAND()')
.first .first
@@ -110,7 +118,9 @@ class PostsController < ApplicationController
post = post =
Post Post
.includes(:uploaded_user, :parents, :children, .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 .with_attached_thumbnail
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
@@ -125,7 +135,7 @@ class PostsController < ApplicationController
child_posts:, child_posts:,
sibling_posts:, sibling_posts:,
related:) related:)
.merge(tags: build_tag_tree_for(post.tags)) .merge(tags: build_tag_tree_for(post))
end end
def create def create
@@ -149,11 +159,15 @@ class PostsController < ApplicationController
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! 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) TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) 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) sync_parent_posts!(post, parent_post_ids)
@@ -166,6 +180,10 @@ class PostsController < ApplicationController
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' } render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue MiniMagick::Error rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] } render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e rescue ArgumentError => e
@@ -224,6 +242,8 @@ class PostsController < ApplicationController
original_created_from:, original_created_from:,
original_created_before:, original_created_before:,
tag_names:, tag_names:,
video_ms_param: params[:video_ms],
duration_param: params[:duration],
parent_post_ids:) parent_post_ids:)
snapshot_to_apply = snapshot_to_apply =
@@ -254,12 +274,16 @@ class PostsController < ApplicationController
post.reload post.reload
json = PostRepr.base(post, current_user) 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 render json:, status: :ok
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] } render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
rescue Tag::DeprecatedTagNormalisationError rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue ArgumentError => e rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] } render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@@ -358,7 +382,7 @@ class PostsController < ApplicationController
def tagged_post_ids_for(name) = def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id) 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| desired_tags.each do |t|
t.save! if t.new_record? t.save! if t.new_record?
end end
@@ -377,13 +401,21 @@ class PostsController < ApplicationController
end end
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| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user) pt.discard_by!(current_user)
end end
end end
def build_tag_tree_for tags def build_tag_tree_for post
tags = tags.reject(&:deprecated?).to_a post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id) tag_ids = tags.map(&:id)
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids) 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 root_ids = tag_ids - child_ids
tags_by_id = tags.index_by(&:id) 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 = { } memo = { }
@@ -405,8 +440,10 @@ class PostsController < ApplicationController
tag = tags_by_id[tag_id] tag = tags_by_id[tag_id]
return nil unless tag return nil unless tag
sections = sections_by_tag_id.fetch(tag_id, [])
if path.include?(tag_id) if path.include?(tag_id)
return TagRepr.inline(tag).merge(children: []) return TagRepr.inline(tag).merge(children: [], sections:)
end end
if memo.key?(tag_id) if memo.key?(tag_id)
@@ -418,7 +455,7 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) } 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 end
root_ids.filter_map { |id| build_node.call(id, []) } root_ids.filter_map { |id| build_node.call(id, []) }
@@ -487,6 +524,7 @@ class PostsController < ApplicationController
def post_snapshot_from_version version def post_snapshot_from_version version
{ title: version.title, { 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_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before), original_created_before: snapshot_time(version.original_created_before),
tag_names: editable_tag_names_from_version(version), tag_names: editable_tag_names_from_version(version),
@@ -499,6 +537,7 @@ class PostsController < ApplicationController
def post_snapshot_from_record post def post_snapshot_from_record post
{ title: post.title, { title: post.title,
video_ms: post.video_ms,
original_created_from: snapshot_time(post.original_created_from), original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before), original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post), tag_names: editable_tag_names_from_post(post),
@@ -506,16 +545,41 @@ class PostsController < ApplicationController
end end
def editable_tag_names_from_post post def editable_tag_names_from_post post
post.tags.not_nico.where(deprecated_at: nil) post
.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') .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 end
def post_incoming_snapshot title:, original_created_from:, original_created_before:, 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:, { title:,
video_ms:,
original_created_from: snapshot_time(original_created_from), original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before), 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 } parent_post_ids: parent_post_ids.sort }
end end
@@ -538,11 +602,8 @@ class PostsController < ApplicationController
value.to_s value.to_s
end end
def incoming_tag_names_for_snapshot raw_tag_names def section_literal section
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false, "[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
deny_deprecated: true)
Tag.expand_parent_tags(tags).reject(&:deprecated?).map(&:name).uniq.sort
end end
def post_conflict_json post:, base_version_no:, base_snapshot:, 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 def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル', [scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot), base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:video_ms, '動画時間',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)', scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot), base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)', scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
@@ -626,11 +689,14 @@ class PostsController < ApplicationController
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title], post.update!(title: snapshot[:title],
video_ms: snapshot[:video_ms],
original_created_from: snapshot[:original_created_from], original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before]) original_created_before: snapshot[:original_created_before])
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true) deny_deprecated: true,
with_sections: true) =>
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a readonly_tags = post.tags.nico.to_a
@@ -638,14 +704,17 @@ class PostsController < ApplicationController
tags = readonly_tags + editable_tags tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) 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]) sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end end
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot 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], [_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1], current_snapshot[_1],
incoming_snapshot[_1])] incoming_snapshot[_1])]
@@ -689,4 +758,44 @@ class PostsController < ApplicationController
render_validation_error record render_validation_error record
end end
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 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 before_validation :normalise_url
validates :url, presence: true, uniqueness: true 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 :validate_original_created_range
validate :url_must_be_http_url validate :url_must_be_http_url
@@ -83,7 +84,42 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil) super(options).merge(thumbnail: nil)
end 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) 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 :created_user, class_name: 'User', optional: true
belongs_to :deleted_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 :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
validates :post_id, uniqueness: { 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 belongs_to :parent, class_name: 'Post', optional: true
validates :url, presence: true validates :url, presence: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range validate :validate_original_created_range
+44 -2
ファイルの表示
@@ -1,7 +1,49 @@
class Setting < ApplicationRecord class Setting < ApplicationRecord
THEMES = ['system', 'light', 'dark'].freeze
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 belongs_to :user
validates :user_id, presence: true validates :user_id, presence: true
validates :key, presence: true, length: { maximum: 255 } validates :user_id, uniqueness: true
validates :value, presence: 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 end
+112 -3
ファイルの表示
@@ -17,6 +17,16 @@ class Tag < ApplicationRecord
end end
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 :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', 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' 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, def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true, with_no_deerjikist: true,
deny_nico: 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:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end
sections = { }
tags = tag_names.map do |name| tags = tag_names.map do |name|
raw_name = name
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] 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| find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated? if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name] raise DeprecatedTagNormalisationError, [tag.name]
end end
tag.update!(category: cat) if cat && tag.category != cat 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
end end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) 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 << 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 end
def self.expand_parent_tags tags def self.expand_parent_tags tags
@@ -150,6 +194,45 @@ class Tag < ApplicationRecord
(result + tags).uniq { |t| t.id } (result + tags).uniq { |t| t.id }
end 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: def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip) tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id? tn = tn.canonical if tn.canonical_id?
@@ -246,6 +329,32 @@ class Tag < ApplicationRecord
end end
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 def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present? if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.' errors.add :deprecated_at, 'ニコタグは廃止できません.'
+24
ファイルの表示
@@ -6,6 +6,7 @@ class TagImplication < ApplicationRecord
validates :parent_tag_id, presence: true validates :parent_tag_id, presence: true
validate :parent_tag_mustnt_be_itself validate :parent_tag_mustnt_be_itself
validate :parent_tag_mustnt_create_cycle
private private
@@ -14,4 +15,27 @@ class TagImplication < ApplicationRecord
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.' errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
end end
end end
def parent_tag_mustnt_create_cycle
return if tag_id.blank? || parent_tag_id.blank?
return if errors[:parent_tag_id].present?
seen = { }
stack = [parent_tag_id]
until stack.empty?
current_id = stack.pop
next if seen[current_id]
seen[current_id] = true
if current_id == tag_id
errors.add :parent_tag_id, '親タグに子孫タグを指定すると循環します.'
errors.add :base, 'タグの親子関係が循環します.'
return
end
stack.concat(TagImplication.where(tag_id: current_id).pluck(:parent_tag_id))
end
end
end end
+1 -1
ファイルの表示
@@ -7,7 +7,7 @@ class User < ApplicationRecord
has_many :created_posts, has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify 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 :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy has_many :user_post_views, dependent: :destroy
+12 -3
ファイルの表示
@@ -8,6 +8,7 @@ module PostRepr
:url, :url,
:title, :title,
:thumbnail_base, :thumbnail_base,
:video_ms,
:original_created_from, :original_created_from,
:original_created_before, :original_created_before,
:created_at, :created_at,
@@ -18,7 +19,7 @@ module PostRepr
def base post, current_user = nil def base post, current_user = nil
json = common(post) 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['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false json['viewed'] = current_user ? current_user.viewed?(post) : false
json json
@@ -52,8 +53,16 @@ module PostRepr
.merge('thumbnail' => thumbnail_url(post)) .merge('thumbnail' => thumbnail_url(post))
end end
def tag_json tags def tag_json post
tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) } 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 end
def thumbnail_url post def thumbnail_url post
+1
ファイルの表示
@@ -23,6 +23,7 @@ class PostVersionRecorder < VersionRecorder
{ title: @record.title, { title: @record.title,
url: @record.url, url: @record.url,
thumbnail_base: @record.thumbnail_base, thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '), tags: @record.snapshot_tag_names.join(' '),
parent_post_ids: @record.snapshot_parent_post_ids.join(' '), parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from, original_created_from: @record.original_created_from,
+3
ファイルの表示
@@ -81,6 +81,9 @@ Rails.application.routes.draw do
end end
end end
get 'users/settings', to: 'user_settings#show'
patch 'users/settings', to: 'user_settings#update'
resources :users, only: [:create, :update] do resources :users, only: [:create, :update] do
collection do collection do
post :verify 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. # 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| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", 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" t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end 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| create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "tag_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 "original_created_before"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" 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 ["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", "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 ["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 "`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" t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
end end
@@ -348,18 +364,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "version_no", 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 ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true 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" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.string "key", null: false
t.json "value", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_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 end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| 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_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id" 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", "posts"
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" 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
+36
ファイルの表示
@@ -0,0 +1,36 @@
require 'rails_helper'
RSpec.describe TagImplication, type: :model do
it 'rejects a parent tag that would create a cycle' do
child = create(:tag, name: 'tag_implication_cycle_child')
parent = create(:tag, name: 'tag_implication_cycle_parent')
described_class.create!(tag: child, parent_tag: parent)
implication = described_class.new(tag: parent, parent_tag: child)
expect(implication).not_to be_valid
expect(implication.errors[:parent_tag_id]).to include(
'親タグに子孫タグを指定すると循環します.'
)
expect(implication.errors[:base]).to be_present
end
it 'terminates even when existing data already contains a cycle' do
child = create(:tag, name: 'tag_implication_existing_cycle_child')
parent = create(:tag, name: 'tag_implication_existing_cycle_parent')
ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor')
described_class.create!(tag: parent, parent_tag: ancestor)
described_class.insert_all!(
[
{ tag_id: ancestor.id, parent_tag_id: parent.id,
created_at: Time.current, updated_at: Time.current }
]
)
implication = described_class.new(tag: child, parent_tag: parent)
expect(implication).to be_valid
end
end
+100 -1
ファイルの表示
@@ -19,6 +19,95 @@ RSpec.describe Tag, type: :model do
expect(error.tag_names).to eq([deprecated_tag.name]) expect(error.tag_names).to eq([deprecated_tag.name])
} }
end 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 end
describe '.expand_parent_tags' do describe '.expand_parent_tags' do
@@ -54,7 +143,17 @@ RSpec.describe Tag, type: :model do
first = create(:tag, name: 'expand_cycle_first') first = create(:tag, name: 'expand_cycle_first')
second = create(:tag, name: 'expand_cycle_second') second = create(:tag, name: 'expand_cycle_second')
TagImplication.create!(tag: first, parent_tag: second) TagImplication.create!(tag: first, parent_tag: second)
TagImplication.create!(tag: second, parent_tag: first) now = Time.current
TagImplication.insert_all!(
[
{
tag_id: second.id,
parent_tag_id: first.id,
created_at: now,
updated_at: now
}
]
)
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second) expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
end end
+209
ファイルの表示
@@ -54,6 +54,7 @@ RSpec.describe 'Posts API', type: :request do
title: post.title, title: post.title,
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
video_ms: post.video_ms,
tags: post.snapshot_tag_names.join(' '), tags: post.snapshot_tag_names.join(' '),
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, 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") expect(all_tag_names).to include("spec_tag")
end 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 context "when q is provided" do
it "filters posts by q (hit case)" do it "filters posts by q (hit case)" do
get "/posts", params: { tags: "spec_tag" } 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') expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent')
end 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 context "when nico tag already exists in tags" do
before do before do
Tag.find_undiscard_or_create_by!( Tag.find_undiscard_or_create_by!(
+11
ファイルの表示
@@ -56,6 +56,17 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
end end
it 'returns 422 and does not create relation when the new link makes a cycle' do
TagImplication.create!(tag: parent, parent_tag: child)
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(TagImplication.where(tag: child, parent_tag: parent)).not_to exist
end
end end
context "when Tag.find raises (invalid ids)" do context "when Tag.find raises (invalid ids)" do
+11 -3
ファイルの表示
@@ -797,7 +797,17 @@ RSpec.describe 'Tags API', type: :request do
) )
TagImplication.create!(tag: first, parent_tag: root_material) TagImplication.create!(tag: first, parent_tag: root_material)
TagImplication.create!(tag: second, parent_tag: first) TagImplication.create!(tag: second, parent_tag: first)
TagImplication.create!(tag: first, parent_tag: second) now = Time.current
TagImplication.insert_all!(
[
{
tag_id: first.id,
parent_tag_id: second.id,
created_at: now,
updated_at: now
}
]
)
get '/tags/with-depth', params: { parent: root_material.id } get '/tags/with-depth', params: { parent: root_material.id }
@@ -1364,8 +1374,6 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'parent_tags に指定すると循環する tag は 422 にする' do it 'parent_tags に指定すると循環する tag は 422 にする' do
pending '#332 で対応予定'
child = Tag.create!( child = Tag.create!(
tag_name: TagName.create!(name: 'put_cycle_child'), tag_name: TagName.create!(name: 'put_cycle_child'),
category: :general category: :general
+20 -15
ファイルの表示
@@ -6,9 +6,11 @@ import { BrowserRouter,
Routes, Routes,
useLocation } from 'react-router-dom' useLocation } from 'react-router-dom'
import DevModeWatermark from '@/components/DevModeWatermark'
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider' import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { UserSettingsProvider } from '@/components/users/UserSettingsProvider'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
@@ -144,23 +146,26 @@ const App: FC = () => {
return ( return (
<> <>
<RouteBlockerOverlay/> <UserSettingsProvider user={user}>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<BrowserRouter> <BrowserRouter>
<DialogueProvider> <DialogueProvider>
<LayoutGroup> <LayoutGroup>
<motion.div <motion.div
layout="position" layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="flex flex-col h-dvh w-full overflow-y-hidden"> className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/> <TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/> <RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div> </motion.div>
</LayoutGroup> </LayoutGroup>
<Toaster/> <Toaster/>
</DialogueProvider> </DialogueProvider>
</BrowserRouter> </BrowserRouter>
</UserSettingsProvider>
</>) </>)
} }
+46
ファイルの表示
@@ -0,0 +1,46 @@
import nikumaru from '@/assets/fonts/nikumaru.otf'
import type { FC } from 'react'
const ROW_COUNT = 8
const COLUMN_COUNT = 12
const DevModeWatermark: FC = () => {
return (
<div
aria-hidden="true"
className="pointer-events-none select-none fixed inset-0 overflow-hidden z-0">
<style>{`
@font-face {
font-family: 'Nikumaru';
src: url(${nikumaru}) format('opentype');
}
`}</style>
<div className="absolute -inset-32 flex flex-col justify-center gap-12 py-48">
{Array.from ({ length: ROW_COUNT }, (_, rowIndex) => (
<div
key={rowIndex}
className={
'flex min-h-32 items-center gap-12 '
+ (rowIndex % 2 === 0 ? 'translate-x-0' : 'translate-x-32')
}>
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
<span
key={columnIndex}
className={
'whitespace-nowrap text-3xl font-bold '
+ 'tracking-[0.3em] text-neutral-950/5 '
+ 'dark:text-white/10'
}
style={{ fontFamily: 'Nikumaru' }}>
</span>))}
</div>))}
</div>
</div>)
}
export default DevModeWatermark
+5 -2
ファイルの表示
@@ -87,10 +87,13 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
setDropRef (node) setDropRef (node)
}} }}
style={style} 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} {...attributes}
{...listeners}> {...listeners}>
<motion.div <motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/> <TagLink tag={tag} nestLevel={nestLevel}/>
@@ -98,4 +101,4 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
</div>) </div>)
} }
export default DraggableDroppableTagRow export default DraggableDroppableTagRow
+9 -1
ファイルの表示
@@ -318,12 +318,20 @@ const MobileMaterialTreeNode: FC<{ depth?: number
<TagLink <TagLink
tag={sidebarTagToTag (tag)} tag={sidebarTagToTag (tag)}
title={tag.name} title={tag.name}
truncateOnMobile
withCount={false} withCount={false}
withWiki={false} withWiki={false}
to={materialPath (tag.id, materialFilter)} to={materialPath (tag.id, materialFilter)}
className="block overflow-hidden text-ellipsis whitespace-nowrap className="block overflow-hidden text-ellipsis whitespace-nowrap
[max-inline-size:var(--tag-link-inline-size)] [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> </div>
{tag.hasChildren && ( {tag.hasChildren && (
<button <button
+23
ファイルの表示
@@ -66,4 +66,27 @@ describe ('PostEditForm', () => {
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 })) expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) 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 PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
@@ -9,29 +9,35 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import { updatePost } from '@/lib/posts' import { updatePost } from '@/lib/posts'
import { inputClass } from '@/lib/utils' import { inputClass, msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { Post, Tag } from '@/types' import type { Post, TagWithSections } from '@/types'
type PostFormField = 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 tagsToStr = (tags: TagWithSections[]): string => {
const result: Tag[] = [] const result: Omit<TagWithSections, 'children'>[] = []
const walk = (tag: Tag) => { const walk = (tag: TagWithSections) => {
const { children, ...rest } = tag const { children, ...rest } = tag
result.push (rest) result.push (rest)
children?.forEach (walk) children.forEach (walk)
} }
tags.filter (t => t.category !== 'nico').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 PostEditForm: FC<Props> = ({ post, onSave }) => {
const [disabled, setDisabled] = useState (false) const [disabled, setDisabled] = useState (false)
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> () useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = const [originalCreatedBefore, setOriginalCreatedBefore] =
@@ -52,6 +59,10 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [tags, setTags] = useState<string> ('') const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title) const [title, setTitle] = useState (post.title)
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const dialogue = useDialogue () const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => { const update = async (...args: Parameters<typeof updatePost>) => {
@@ -63,6 +74,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
onSave ({ ...post, onSave ({ ...post,
versionNo: data.versionNo, versionNo: data.versionNo,
title: data.title, title: data.title,
videoMs: data.videoMs,
tags: data.tags, tags: data.tags,
parentPosts: data.parentPosts, parentPosts: data.parentPosts,
childPosts: data.childPosts, childPosts: data.childPosts,
@@ -102,6 +114,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{ {
// TODO: 差分 UI // TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds, await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true }) { baseVersionNo: post.versionNo, merge: true })
return return
@@ -110,6 +123,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
if (action === 'overwrite') if (action === 'overwrite')
{ {
await update ({ id: post.id, title, tags, parentPostIds, await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true }) { baseVersionNo: post.versionNo, force: true })
return return
@@ -124,6 +138,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
try try
{ {
await update ({ id: post.id, title, tags, parentPostIds, await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo }) { baseVersionNo: post.versionNo })
} }
@@ -134,7 +149,8 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
} }
useEffect (() => { useEffect (() => {
setTags(tagsToStr (post.tags)) setTags (tagsToStr (post.tags))
setDuration (videoMsToDurationValue (post.videoMs))
}, [post]) }, [post])
return ( return (
@@ -149,7 +165,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
disabled={disabled} disabled={disabled}
className={inputClass (invalid)} className={inputClass (invalid)}
value={title ?? ''} value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>)} onChange={e => setTitle (e.target.value)}/>)}
</FormField> </FormField>
{/* 親投稿 */} {/* 親投稿 */}
@@ -181,6 +197,20 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/> 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}> <Button type="submit" disabled={disabled}>
@@ -188,4 +218,5 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
</form>) </form>)
} }
export default PostEditForm export default PostEditForm
+6 -3
ファイルの表示
@@ -68,9 +68,12 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
setBounds ({ start, end }) setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token, nico: '0' } }) const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
setSuggestions (data.filter (t => t.postCount > 0)) q: token,
setSuggestionsVsbl (suggestions.length > 0) nico: '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
} }
return ( return (
+1 -1
ファイルの表示
@@ -72,4 +72,4 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
</div>) </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 { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react' 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 = ( const renderTagTree = (
tag: Tag, tag: TagWithSections,
nestLevel: number, nestLevel: number,
path: string, path: string,
suppressClickRef: MutableRefObject<boolean>, suppressClickRef: MutableRefObject<boolean>,
@@ -62,8 +62,9 @@ const renderTagTree = (
const isDescendant = ( const isDescendant = (
root: Tag, root: TagWithSections,
targetId: number): boolean => { targetId: number,
): boolean => {
if (!(root.children)) if (!(root.children))
return false return false
@@ -81,8 +82,9 @@ const isDescendant = (
const findTag = ( const findTag = (
byCat: TagByCategory, byCat: TagByCategory,
id: number): Tag | undefined => { id: number,
const walk = (nodes: Tag[]): Tag | undefined => { ): TagWithSections | undefined => {
const walk = (nodes: TagWithSections[]): TagWithSections | undefined => {
for (const t of nodes) for (const t of nodes)
{ {
if (t.id === id) 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)[]) 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 return tagsTmp
}, [post]) }, [post])
@@ -373,4 +375,4 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
</SidebarComponent>) </SidebarComponent>)
} }
export default TagDetailSidebar export default TagDetailSidebar
+38 -12
ファイルの表示
@@ -1,4 +1,5 @@
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -9,6 +10,7 @@ import type { Tag } from '@/types'
type CommonProps = { type CommonProps = {
tag: Tag tag: Tag
nestLevel?: number nestLevel?: number
truncateOnMobile?: boolean
withWiki?: boolean withWiki?: boolean
withCount?: boolean } withCount?: boolean }
@@ -30,9 +32,11 @@ type Props =
const TagLink: FC<Props> = ({ tag, const TagLink: FC<Props> = ({ tag,
nestLevel = 0, nestLevel = 0,
linkFlg = true, linkFlg = true,
truncateOnMobile = false,
withWiki = true, withWiki = true,
withCount = true, withCount = true,
className, className,
title,
...props }) => { ...props }) => {
const spanClass = cn ( const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
@@ -41,11 +45,18 @@ const TagLink: FC<Props> = ({ tag,
spanClass, spanClass,
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`, `hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`) `dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
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 ( return (
<> <span className={rootClass}>
{(linkFlg && withWiki) && ( {(linkFlg && withWiki) && (
<span className="mr-1"> <span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? ( ? (
tag.materialId == null && !(tag.hasDeerjikists) tag.materialId == null && !(tag.hasDeerjikists)
@@ -100,33 +111,48 @@ const TagLink: FC<Props> = ({ tag,
</span>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span
className="ml-1 mr-1" className="ml-1 mr-1 shrink-0"
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}> style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)} </span>)}
{tag.matchedAlias != null && ( {tag.matchedAlias != null && (
<> <>
<span className={cn (spanClass, className)} {...props}> <span
{tag.matchedAlias} title={textTitle}
className={cn (spanClass, textClass, className)}
{...props}>
<ResponsiveMarqueeText
text={tag.matchedAlias}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span> </span>
<> </> <span className="shrink-0"> </span>
</>)} </>)}
{linkFlg {linkFlg
? ( ? (
<PrefetchLink <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={cn (linkClass, className)} title={textTitle}
className={cn (linkClass, textClass, className)}
{...props}> {...props}>
{tag.name} <ResponsiveMarqueeText
text={tag.name}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</PrefetchLink>) </PrefetchLink>)
: ( : (
<span className={cn (spanClass, className)} <span
title={textTitle}
className={cn (spanClass, textClass, className)}
{...props}> {...props}>
{tag.name} <ResponsiveMarqueeText
text={tag.name}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>)} </span>)}
{withCount && ( {withCount && (
<span className="ml-1">{tag.postCount}</span>)} <span className={countClass}>{tag.postCount}</span>)}
</>) </span>)
} }
export default TagLink export default TagLink
+5 -3
ファイルの表示
@@ -20,12 +20,14 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
rounded shadow"> rounded shadow">
{suggestions.map ((tag, i) => ( {suggestions.map ((tag, i) => (
<li key={tag.id} <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')} i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
onMouseDown={() => onSelect (tag)}> 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>))} </li>))}
</ul>) </ul>)
} }
export default TagSearchBox export default TagSearchBox
+3 -2
ファイルの表示
@@ -64,8 +64,9 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
<ul> <ul>
{CATEGORIES.flatMap (cat => cat in tags ? ( {CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => ( 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 <motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ tag.id }`}> layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/> <TagLink tag={tag} onClick={onClick}/>
@@ -128,4 +129,4 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</SidebarComponent>) </SidebarComponent>)
} }
export default TagSidebar export default TagSidebar
+17 -1
ファイルの表示
@@ -26,7 +26,7 @@ describe ('TagInput', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete', '/tags/autocomplete',
{ params: { q: '虹夏' } }, { params: { q: '虹夏', nico: '0' } },
) )
}) })
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
@@ -41,4 +41,20 @@ describe ('TagInput', () => {
expect (api.apiGet).not.toHaveBeenCalled () expect (api.apiGet).not.toHaveBeenCalled ()
expect (setValue).toHaveBeenCalledWith (' ') 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 = { type Props = {
includeNico?: boolean
describedBy?: string describedBy?: string
invalid?: boolean invalid?: boolean
value: string value: string
setValue: (value: string) => void } 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 [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([]) const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
@@ -32,10 +39,12 @@ const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
return return
} }
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } }) const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
setSuggestions (data.filter (t => t.postCount > 0)) q,
if (suggestions.length > 0) nico: includeNico ? '1' : '0' } })
setSuggestionsVsbl (true) const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
} }
// TODO: TagSearch からのコピペのため,共通化を考へる. // TODO: TagSearch からのコピペのため,共通化を考へる.
+113
ファイルの表示
@@ -0,0 +1,113 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { 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)
const applyTheme = (theme: UserSettings['theme']) => {
const root = document.documentElement
const media = window.matchMedia ('(prefers-color-scheme: dark)')
const sync = () => {
const dark = theme === 'dark' || (theme === 'system' && media.matches)
root.classList.toggle ('dark', dark)
root.style.colorScheme = dark ? 'dark' : 'light'
}
sync ()
if (theme !== 'system')
return () => { }
const onChange = () => sync ()
media.addEventListener ('change', onChange)
return () => {
media.removeEventListener ('change', onChange)
}
}
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 (() => applyTheme (settings.theme), [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
}
+40
ファイルの表示
@@ -87,6 +87,29 @@
-moz-osx-font-smoothing: grayscale; -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 a
{ {
font-weight: 500; font-weight: 500;
@@ -132,3 +155,20 @@ body
0%, 100% { color: #f87171; } 0%, 100% { color: #f87171; }
50% { color: #60a5fa; } 50% { color: #60a5fa; }
} }
.tag-marquee
{
position: relative;
}
.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, id: 5,
title: 'new title', title: 'new title',
tags: 'tag', tags: 'tag',
duration: null,
parentPostIds: '1 2', parentPostIds: '1 2',
originalCreatedFrom: null, originalCreatedFrom: null,
originalCreatedBefore: '2026-01-02T00:00:00Z', originalCreatedBefore: '2026-01-02T00:00:00Z',
@@ -82,6 +83,7 @@ describe ('posts API functions', () => {
{ {
title: 'new title', title: 'new title',
tags: 'tag', tags: 'tag',
duration: null,
parent_post_ids: '1 2', parent_post_ids: '1 2',
original_created_from: null, original_created_from: null,
original_created_before: '2026-01-02T00:00:00Z', original_created_before: '2026-01-02T00:00:00Z',
+2
ファイルの表示
@@ -44,6 +44,7 @@ export const updatePost = async (
post: { id: number post: { id: number
title: string | null title: string | null
tags: string tags: string
duration: string | null
parentPostIds: string parentPostIds: string
originalCreatedFrom: string | null originalCreatedFrom: string | null
originalCreatedBefore: string | null }, originalCreatedBefore: string | null },
@@ -55,6 +56,7 @@ export const updatePost = async (
`/posts/${ post.id }`, `/posts/${ post.id }`,
{ title: post.title, { title: post.title,
tags: post.tags, tags: post.tags,
duration: post.duration,
parent_post_ids: post.parentPostIds, parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom, original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore }, original_created_before: post.originalCreatedBefore },
+282
ファイルの表示
@@ -0,0 +1,282 @@
import { apiGet, apiPatch } from '@/lib/api'
import type { FetchPostsOrder, FetchTagsOrder } from '@/types'
// DB-backed user settings. These are worth sharing across browsers for one user.
export type UserSettings = {
theme: 'system' | 'light' | 'dark'
autoFetchTitle: 'auto' | 'manual' | 'off'
autoFetchThumbnail: 'auto' | 'manual' | 'off'
wikiEditorMode: 'split' | 'write' | 'preview' }
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 }
// Browser-local settings. These depend on device or screen context.
export type ClientSettings = {
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 DEFAULT_USER_SETTINGS: UserSettings = {
theme: 'system',
autoFetchTitle: 'manual',
autoFetchThumbnail: 'manual',
wikiEditorMode: 'split' }
export const THEME_OPTIONS = ['system', 'light', 'dark'] as const
export const AUTO_FETCH_OPTIONS = ['auto', 'manual', 'off'] as const
export const WIKI_EDITOR_MODE_OPTIONS = ['split', 'write', 'preview'] 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 = 'name:asc'
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'
export const fetchUserSettings = async (): Promise<UserSettings> =>
await apiGet<UserSettings> ('/users/settings')
export const updateUserSettings = async (
settings: Partial<{
theme: UserSettings['theme']
auto_fetch_title: UserSettings['autoFetchTitle']
auto_fetch_thumbnail: UserSettings['autoFetchThumbnail']
wiki_editor_mode: UserSettings['wikiEditorMode']
}>,
): Promise<UserSettings> => await apiPatch<UserSettings> ('/users/settings', settings)
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
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 { describe, expect, it } from 'vitest'
import { cn, originalCreatedAtString, toDate } from '@/lib/utils' import { cn, msToTime, originalCreatedAtString, toDate } from '@/lib/utils'
describe ('utils', () => { describe ('utils', () => {
it ('converts strings to dates and leaves date instances intact', () => { it ('converts strings to dates and leaves date instances intact', () => {
@@ -26,3 +26,14 @@ describe ('utils', () => {
).toContain ('時刻不詳') ).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 => export const inputClass = (invalid?: boolean, className?: string): string =>
cn ('w-full rounded border p-2', cn ('w-full rounded border p-2',
(invalid (invalid
+7 -9
ファイルの表示
@@ -24,6 +24,10 @@ import { recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery'
import { isQuestionHardFilteredAfterAnswers, import { isQuestionHardFilteredAfterAnswers,
monthForCondition } from '@/lib/gekanatorQuestionFilters' monthForCondition } from '@/lib/gekanatorQuestionFilters'
import { gekanatorKeys } from '@/lib/queryKeys' import { gekanatorKeys } from '@/lib/queryKeys'
import {
getClientGekanatorBackgroundMotion,
setClientGekanatorBackgroundMotion,
} from '@/lib/settings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -185,7 +189,6 @@ const softenedAnswerWeight = .35
const confidenceTemperature = 6 const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1' const gameStorageKey = 'gekanator:game:v1'
const recentGamesStorageKey = 'gekanator:recent-games:v1' const recentGamesStorageKey = 'gekanator:recent-games:v1'
const backgroundMotionStorageKey = 'gekanator:background-motion:v1'
const maxStoredRecentGames = 12 const maxStoredRecentGames = 12
const specialOriginalMonthDayLabels: Record<string, string> = { const specialOriginalMonthDayLabels: Record<string, string> = {
'1-1': '元日', '1-1': '元日',
@@ -429,18 +432,13 @@ const storeRecentGameSummary = (
const loadBackgroundMotionMode = (): BackgroundMotionMode => { const loadBackgroundMotionMode = (): BackgroundMotionMode => {
const fallbackMode = 'on'
try try
{ {
const raw = localStorage.getItem (backgroundMotionStorageKey) return getClientGekanatorBackgroundMotion ()
if (raw === 'off' || raw === 'calm' || raw === 'on')
return raw
return fallbackMode
} }
catch catch
{ {
return fallbackMode return 'on'
} }
} }
@@ -3916,7 +3914,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
useEffect (() => { useEffect (() => {
try try
{ {
localStorage.setItem (backgroundMotionStorageKey, backgroundMotionMode) setClientGekanatorBackgroundMotion (backgroundMotionMode)
} }
catch catch
{ {
+1 -1
ファイルの表示
@@ -79,7 +79,7 @@ const MaterialNewPage: FC = () => {
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
{/* タグ */} {/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}> <FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<TagInput <TagInput
describedBy={describedBy} describedBy={describedBy}
+5 -1
ファイルの表示
@@ -91,9 +91,13 @@ const PostHistoryPage: FC = () => {
.filter (p => p.type !== 'removed') .filter (p => p.type !== 'removed')
.map (p => p.id) .map (p => p.id)
.join (' ') .join (' ')
const duration =
change.videoMs.current == null
? null
: String (change.videoMs.current / 1_000)
const originalCreatedFrom = change.originalCreatedFrom.current const originalCreatedFrom = change.originalCreatedFrom.current
const originalCreatedBefore = change.originalCreatedBefore.current const originalCreatedBefore = change.originalCreatedBefore.current
await updatePost ({ id, title, tags, parentPostIds, await updatePost ({ id, title, tags, duration, parentPostIds,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ force: true }) { force: true })
+94 -5
ファイルの表示
@@ -1,23 +1,57 @@
import { useQuery } from '@tanstack/react-query' 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 { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import PostList from '@/components/PostList' import PostList from '@/components/PostList'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import TagSidebar from '@/components/TagSidebar' import TagSidebar from '@/components/TagSidebar'
import WikiBody from '@/components/WikiBody' import WikiBody from '@/components/WikiBody'
import FormField from '@/components/common/FormField'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import TabGroup, { Tab } from '@/components/common/TabGroup' import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts' import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' import { postsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_POST_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
POST_LIST_ORDER_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { inputClass } from '@/lib/utils'
import { fetchWikiPageByTitle } from '@/lib/wiki' import { fetchWikiPageByTitle } from '@/lib/wiki'
import type { FC } from 'react' import type { FC } from 'react'
import type { WikiPage } from '@/types' import type { FetchPostsOrder, WikiPage } from '@/types'
const postOrderLabel: Record<FetchPostsOrder, string> = {
'title:asc': 'タイトル昇順',
'title:desc': 'タイトル降順',
'url:asc': 'URL 昇順',
'url:desc': 'URL 降順',
'original_created_at:asc': 'オリジナル投稿日時 昇順',
'original_created_at:desc': 'オリジナル投稿日時 降順',
'created_at:asc': '投稿日 昇順',
'created_at:desc': '投稿日 降順',
'updated_at:asc': '更新日 昇順',
'updated_at:desc': '更新日 降順',
}
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 =>
POST_LIST_ORDER_OPTIONS.some (option => option === value)
? value as FetchPostsOrder
: null
const PostListPage: FC = () => { const PostListPage: FC = () => {
@@ -26,6 +60,7 @@ const PostListPage: FC = () => {
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
const location = useLocation () const location = useLocation ()
const navigate = useNavigate ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const tagsQuery = query.get ('tags') ?? '' const tagsQuery = query.get ('tags') ?? ''
const anyFlg = query.get ('match') === 'any' const anyFlg = query.get ('match') === 'any'
@@ -33,13 +68,22 @@ const PostListPage: FC = () => {
const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery]) const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery])
const tagsKey = tags.join (' ') const tagsKey = tags.join (' ')
const page = Number (query.get ('page') ?? 1) 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 = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchPostsOrder> ('postList')
?? DEFAULT_POST_LIST_ORDER
)
const keys = { const keys = {
tags: tagsKey, match, page, limit, tags: tagsKey, match, page, limit,
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '', url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '', createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const order } as const
const { data, isLoading: loading } = useQuery ({ const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index (keys), queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) }) queryFn: () => fetchPosts (keys) })
@@ -47,6 +91,19 @@ const PostListPage: FC = () => {
const cursor = '' const cursor = ''
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('postList', { limit, order })
}, [limit, order])
const updateListQuery = (next: { limit?: 20 | 50 | 100
order?: FetchPostsOrder }) => {
const params = new URLSearchParams (location.search)
params.set ('limit', String (next.limit ?? limit))
params.set ('order', next.order ?? order)
params.set ('page', '1')
navigate (`${ location.pathname }?${ params.toString () }`)
}
useLayoutEffect (() => { useLayoutEffect (() => {
scroll (0, 0) scroll (0, 0)
@@ -89,6 +146,38 @@ const PostListPage: FC = () => {
}}/> }}/>
<MainArea> <MainArea>
<div className="mb-4 flex flex-wrap gap-4 rounded-xl border border-border bg-background/80 p-4">
<FormField label="表示件数">
{() => (
<select
className={inputClass (false)}
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>)}
</FormField>
<FormField label="並び順">
{() => (
<select
className={inputClass (false)}
value={order}
onChange={ev => updateListQuery ({
order: ev.target.value as FetchPostsOrder,
})}>
{POST_LIST_ORDER_OPTIONS.map (value => (
<option key={value} value={value}>
{postOrderLabel[value]}
</option>))}
</select>)}
</FormField>
</div>
<TabGroup> <TabGroup>
<Tab name="広場"> <Tab name="広場">
{posts.length > 0 {posts.length > 0
+18
ファイルの表示
@@ -62,6 +62,24 @@ describe ('PostNewPage', () => {
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' }) 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 () => { it ('shows 422 validation errors for post fields', async () => {
api.apiGet.mockResolvedValue ([]) api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true) api.isApiError.mockReturnValue (true)
+128 -37
ファイルの表示
@@ -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 { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@@ -9,6 +9,7 @@ import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { useUserSettings } from '@/components/users/UserSettingsProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
@@ -25,11 +26,12 @@ import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
type PostFormField = type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds' | 'originalCreatedAt' | 'thumbnail' 'url' | 'title' | 'tags' | 'parentPostIds' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => { const PostNewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const { settings } = useUserSettings ()
const navigate = useNavigate () const navigate = useNavigate ()
@@ -40,17 +42,26 @@ const PostNewPage: FC<Props> = ({ user }) => {
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('') const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('') const [tags, setTags] = useState ('')
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) const [duration, setDuration] = useState ('')
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false) const [thumbnailLoading, setThumbnailLoading] = useState (false)
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('') const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
const [title, setTitle] = useState ('') const [title, setTitle] = useState ('')
const [titleAutoFlg, setTitleAutoFlg] = useState (true)
const [titleLoading, setTitleLoading] = useState (false) const [titleLoading, setTitleLoading] = useState (false)
const [url, setURL] = useState ('') const [url, setURL] = useState ('')
const [titleAutoFlg, setTitleAutoFlg] = useState (settings.autoFetchTitle === 'auto')
const [thumbnailAutoFlg, setThumbnailAutoFlg] =
useState (settings.autoFetchThumbnail === 'auto')
const previousURLRef = useRef ('') const previousURLRef = useRef ('')
const thumbnailPreviewRef = useRef ('') const thumbnailPreviewRef = useRef ('')
const titleFetchMode = settings.autoFetchTitle
const thumbnailFetchMode = settings.autoFetchThumbnail
const titleFetchVisible = titleFetchMode !== 'off'
const thumbnailFetchVisible = thumbnailFetchMode !== 'off'
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors () clearValidationErrors ()
@@ -60,6 +71,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
formData.append ('url', url) formData.append ('url', url)
formData.append ('tags', tags) formData.append ('tags', tags)
formData.append ('parent_post_ids', parentPostIds) formData.append ('parent_post_ids', parentPostIds)
if (videoFlg && duration !== '')
formData.append ('duration', duration)
if (thumbnailFile) if (thumbnailFile)
formData.append ('thumbnail', thumbnailFile) formData.append ('thumbnail', thumbnailFile)
if (originalCreatedFrom) if (originalCreatedFrom)
@@ -94,9 +107,15 @@ const PostNewPage: FC<Props> = ({ user }) => {
const fetchTitle = useCallback (async () => { const fetchTitle = useCallback (async () => {
setTitle ('') setTitle ('')
setTitleLoading (true) setTitleLoading (true)
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) try
setTitle (data.title || '') {
setTitleLoading (false) const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
}
finally
{
setTitleLoading (false)
}
}, [url]) }, [url])
const fetchThumbnail = useCallback (async () => { const fetchThumbnail = useCallback (async () => {
@@ -105,20 +124,34 @@ const PostNewPage: FC<Props> = ({ user }) => {
setThumbnailLoading (true) setThumbnailLoading (true)
if (thumbnailPreviewRef.current) if (thumbnailPreviewRef.current)
URL.revokeObjectURL (thumbnailPreviewRef.current) URL.revokeObjectURL (thumbnailPreviewRef.current)
const data = await apiGet<Blob> ('/preview/thumbnail', try
{ params: { url }, responseType: 'blob' }) {
const imageURL = URL.createObjectURL (data) const data = await apiGet<Blob> ('/preview/thumbnail',
setThumbnailPreview (imageURL) { params: { url }, responseType: 'blob' })
setThumbnailFile (new File ([data], const imageURL = URL.createObjectURL (data)
'thumbnail.png', setThumbnailPreview (imageURL)
{ type: data.type || 'image/png' })) setThumbnailFile (new File ([data],
setThumbnailLoading (false) 'thumbnail.png',
{ type: data.type || 'image/png' }))
}
finally
{
setThumbnailLoading (false)
}
}, [url]) }, [url])
useEffect (() => { useEffect (() => {
thumbnailPreviewRef.current = thumbnailPreview thumbnailPreviewRef.current = thumbnailPreview
}, [thumbnailPreview]) }, [thumbnailPreview])
useEffect (() => {
setTitleAutoFlg (settings.autoFetchTitle === 'auto')
}, [settings.autoFetchTitle])
useEffect (() => {
setThumbnailAutoFlg (settings.autoFetchThumbnail === 'auto')
}, [settings.autoFetchThumbnail])
useEffect (() => { useEffect (() => {
if (titleAutoFlg && url) if (titleAutoFlg && url)
fetchTitle () fetchTitle ()
@@ -155,34 +188,78 @@ const PostNewPage: FC<Props> = ({ user }) => {
</FormField> </FormField>
{/* タイトル */} {/* タイトル */}
<FormField <FormField label="タイトル" messages={fieldErrors.title}>
checkBox={{
label: '自動',
checked: titleAutoFlg,
onChange: ev => setTitleAutoFlg (ev.target.checked)}}
label="タイトル"
messages={fieldErrors.title}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input type="text" <div className="space-y-2">
aria-describedby={describedBy} <input type="text"
aria-invalid={invalid} aria-describedby={describedBy}
className={inputClass (invalid)} aria-invalid={invalid}
value={title} className={inputClass (invalid)}
placeholder={titleLoading ? 'Loading...' : ''} value={title}
onChange={ev => setTitle (ev.target.value)} placeholder={titleLoading ? 'Loading...' : ''}
disabled={titleAutoFlg}/>)} onChange={ev => setTitle (ev.target.value)}
disabled={titleLoading}/>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span>
{titleAutoFlg
? 'URL 入力時に自動取得します.'
: titleFetchMode === 'manual'
? '自動取得しません.必要なら手動で取得できます.'
: '取得機能は無効です.'}
</span>
{titleFetchVisible && (
<>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={titleAutoFlg}
onChange={ev => setTitleAutoFlg (ev.target.checked)}/>
<span></span>
</label>
{!(titleAutoFlg) && (
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>)}
</>)}
</div>
</div>)}
</FormField> </FormField>
{/* サムネール */} {/* サムネール */}
<FormField <FormField label="サムネール" messages={fieldErrors.thumbnail}>
checkBox={{
label: '自動',
checked: thumbnailAutoFlg,
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}
label="サムネール"
messages={fieldErrors.thumbnail}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span>
{thumbnailAutoFlg
? 'URL 入力時に自動取得します.'
: thumbnailFetchMode === 'manual'
? '自動取得しません.必要なら手動で取得できます.'
: '取得機能は無効です.'}
</span>
{thumbnailFetchVisible && (
<>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={thumbnailAutoFlg}
onChange={ev => setThumbnailAutoFlg (ev.target.checked)}/>
<span></span>
</label>
{!(thumbnailAutoFlg) && (
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>)}
</>)}
</div>
{thumbnailAutoFlg {thumbnailAutoFlg
? (thumbnailLoading ? (thumbnailLoading
? <p className="text-gray-500 text-sm">Loading...</p> ? <p className="text-gray-500 text-sm">Loading...</p>
@@ -233,6 +310,20 @@ const PostNewPage: FC<Props> = ({ user }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/> 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} <Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
+92 -3
ファイルの表示
@@ -16,6 +16,15 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts' import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' import { postsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_POST_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
POST_LIST_ORDER_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils' import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -31,6 +40,29 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
qs.set (k, t) qs.set (k, t)
} }
const postOrderLabel: Record<FetchPostsOrder, string> = {
'title:asc': 'タイトル昇順',
'title:desc': 'タイトル降順',
'url:asc': 'URL 昇順',
'url:desc': 'URL 降順',
'original_created_at:asc': 'オリジナル投稿日時 昇順',
'original_created_at:desc': 'オリジナル投稿日時 降順',
'created_at:asc': '投稿日 昇順',
'created_at:desc': '投稿日 降順',
'updated_at:asc': '更新日 昇順',
'updated_at:desc': '更新日 降順',
}
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 =>
POST_LIST_ORDER_OPTIONS.some (option => option === value)
? value as FetchPostsOrder
: null
const PostSearchPage: FC = () => { const PostSearchPage: FC = () => {
const location = useLocation () const location = useLocation ()
@@ -41,7 +73,11 @@ const PostSearchPage: FC = () => {
[location.search]) [location.search])
const page = Number (query.get ('page') ?? 1) 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 qURL = query.get ('url') ?? ''
const qTitle = query.get ('title') ?? '' const qTitle = query.get ('title') ?? ''
@@ -53,7 +89,11 @@ const PostSearchPage: FC = () => {
const qCreatedTo = query.get ('created_to') ?? '' const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') ?? '' const qUpdatedFrom = query.get ('updated_from') ?? ''
const qUpdatedTo = query.get ('updated_to') ?? '' 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 [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null) const [createdTo, setCreatedTo] = useState<string | null> (null)
@@ -83,6 +123,10 @@ const PostSearchPage: FC = () => {
const results = data?.posts ?? [] const results = data?.posts ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('postSearch', { limit, order })
}, [limit, order])
useEffect (() => { useEffect (() => {
setURL (qURL ?? '') setURL (qURL ?? '')
setTitle (qTitle ?? '') setTitle (qTitle ?? '')
@@ -113,6 +157,16 @@ const PostSearchPage: FC = () => {
qs.set ('match', matchType) qs.set ('match', matchType)
qs.set ('page', '1') qs.set ('page', '1')
qs.set ('order', order) 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 () }`) navigate (`${ location.pathname }?${ qs.toString () }`)
} }
@@ -137,6 +191,38 @@ const PostSearchPage: FC = () => {
<PageTitle></PageTitle> <PageTitle></PageTitle>
<form onSubmit={handleSearch} className="space-y-2"> <form onSubmit={handleSearch} className="space-y-2">
<div className="grid gap-3 rounded-xl border border-border bg-background/80 p-3 sm:grid-cols-2">
<FormField label="表示件数">
{() => (
<select
className={inputClass (false)}
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>)}
</FormField>
<FormField label="並び順">
{() => (
<select
className={inputClass (false)}
value={order}
onChange={ev => updateListQuery ({
order: ev.target.value as FetchPostsOrder,
})}>
{POST_LIST_ORDER_OPTIONS.map (value => (
<option key={value} value={value}>
{postOrderLabel[value]}
</option>))}
</select>)}
</FormField>
</div>
{/* タイトル */} {/* タイトル */}
<FormField label="タイトル"> <FormField label="タイトル">
{({ invalid }) => ( {({ invalid }) => (
@@ -161,6 +247,7 @@ const PostSearchPage: FC = () => {
<FormField label="タグ"> <FormField label="タグ">
{() => ( {() => (
<TagInput <TagInput
includeNico={true}
value={tagsStr} value={tagsStr}
setValue={setTagsStr}/>)} setValue={setTagsStr}/>)}
</FormField> </FormField>
@@ -296,7 +383,9 @@ const PostSearchPage: FC = () => {
</thead> </thead>
<tbody> <tbody>
{results.map (row => ( {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"> <td className="p-2">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}> <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
<motion.div <motion.div
+88 -2
ファイルの表示
@@ -14,6 +14,15 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { tagsKeys } from '@/lib/queryKeys' import { tagsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_TAG_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
setClientListSettings,
TAG_LIST_ORDER_OPTIONS,
} from '@/lib/settings'
import { fetchTags } from '@/lib/tags' import { fetchTags } from '@/lib/tags'
import { dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
@@ -35,6 +44,29 @@ const boolFromQuery = (value: string | null): boolean =>
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : '' const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const tagOrderLabel: Record<FetchTagsOrder, string> = {
'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': '更新日時 降順',
}
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 =>
TAG_LIST_ORDER_OPTIONS.some (option => option === value)
? value as FetchTagsOrder
: null
const TagListPage: FC = () => { const TagListPage: FC = () => {
const location = useLocation () const location = useLocation ()
@@ -44,7 +76,11 @@ const TagListPage: FC = () => {
const query = useMemo (() => new URLSearchParams (location.search), [location.search]) const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const page = Number (query.get ('page') ?? 1) 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 qName = query.get ('name') ?? ''
const qCategory = (query.get ('category') || null) as Category | null const qCategory = (query.get ('category') || null) as Category | null
@@ -58,7 +94,11 @@ const TagListPage: FC = () => {
const qDeprecated = query.has ('deprecated') const qDeprecated = query.has ('deprecated')
? boolFromQuery (query.get ('deprecated')) ? boolFromQuery (query.get ('deprecated'))
: null : 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 [name, setName] = useState ('')
const [category, setCategory] = useState<Category | null> (null) const [category, setCategory] = useState<Category | null> (null)
@@ -88,6 +128,10 @@ const TagListPage: FC = () => {
const results = data?.tags ?? [] const results = data?.tags ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('tagList', { limit, order })
}, [limit, order])
useEffect (() => { useEffect (() => {
setName (qName) setName (qName)
setCategory (qCategory) setCategory (qCategory)
@@ -121,10 +165,20 @@ const TagListPage: FC = () => {
qs.set ('deprecated', deprecated ? '1' : '0') qs.set ('deprecated', deprecated ? '1' : '0')
qs.set ('page', '1') qs.set ('page', '1')
qs.set ('order', order) qs.set ('order', order)
qs.set ('limit', String (limit))
navigate (`${ location.pathname }?${ qs.toString () }`) 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', const defaultDirection = { name: 'asc',
category: 'asc', category: 'asc',
post_count: 'desc', post_count: 'desc',
@@ -141,6 +195,38 @@ const TagListPage: FC = () => {
<PageTitle></PageTitle> <PageTitle></PageTitle>
<form onSubmit={handleSearch} className="space-y-2"> <form onSubmit={handleSearch} className="space-y-2">
<div className="grid gap-3 rounded-xl border border-border bg-background/80 p-3 sm:grid-cols-2">
<FormField label="表示件数">
{() => (
<select
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}
className={inputClass (false)}>
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>)}
</FormField>
<FormField label="並び順">
{() => (
<select
value={order}
onChange={ev => updateListQuery ({
order: ev.target.value as FetchTagsOrder,
})}
className={inputClass (false)}>
{TAG_LIST_ORDER_OPTIONS.map (value => (
<option key={value} value={value}>
{tagOrderLabel[value]}
</option>))}
</select>)}
</FormField>
</div>
{/* 名前 */} {/* 名前 */}
<FormField label="名前"> <FormField label="名前">
{({ invalid }) => ( {({ invalid }) => (
+60 -5
ファイルの表示
@@ -1,9 +1,10 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import { buildPost, import { buildPost,
buildTag,
buildTheatre, buildTheatre,
buildTheatreComment, buildTheatreComment,
buildTheatreInfo, buildTheatreInfo,
@@ -87,6 +88,10 @@ const weights = buildTheatrePostSelectionWeights ({
tags: [], tags: [],
}], }],
}) })
const watchingUsers = [
{ id: 1, name: 'tester' },
{ id: 2, name: 'another' },
]
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) => const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
renderWithProviders ( renderWithProviders (
@@ -96,6 +101,9 @@ const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
{ route: '/theatres/7' }, { route: '/theatres/7' },
) )
const tagSection = (): HTMLElement =>
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
const mockDefaultApi = () => { const mockDefaultApi = () => {
api.apiGet.mockImplementation ((path: string) => { api.apiGet.mockImplementation ((path: string) => {
switch (path) switch (path)
@@ -132,11 +140,11 @@ const mockDefaultApi = () => {
postId: currentPost.id, postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z', postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000, postElapsedMs: 1_000,
watchingUsers: [{ id: 1, name: 'tester' }], watchingUsers,
skipVote: { skipVote: {
votesCount: 0, votesCount: 0,
requiredCount: 2, requiredCount: 2,
watchingUsersCount: 1, watchingUsersCount: watchingUsers.length,
voted: false, voted: false,
}, },
})) }))
@@ -146,11 +154,11 @@ const mockDefaultApi = () => {
postId: currentPost.id, postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z', postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 2_000, postElapsedMs: 2_000,
watchingUsers: [{ id: 1, name: 'tester' }], watchingUsers,
skipVote: { skipVote: {
votesCount: 1, votesCount: 1,
requiredCount: 2, requiredCount: 2,
watchingUsersCount: 1, watchingUsersCount: watchingUsers.length,
voted: true, voted: true,
}, },
})) }))
@@ -245,6 +253,53 @@ describe ('TheatreDetailPage', () => {
expect (postEmbed.seek).not.toHaveBeenCalledWith (0) expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
}) })
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
const parentTag = buildTag ({
id: 11,
name: '親タグ',
category: 'general',
children: [childTag],
})
const duplicateParentTag = buildTag ({
id: 13,
name: '別親タグ',
category: 'general',
children: [childTag],
})
postsApi.fetchPost.mockResolvedValueOnce (buildPost ({
...currentPost,
tags: [parentTag, duplicateParentTag],
}))
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })[0])
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByText ('↳').length).toBeGreaterThan (0)
fireEvent.click (screen.getByRole ('button', { name: '2 列 A 型' }))
fireEvent.click (screen.getAllByRole ('button', { name: '横並び' })[0])
await waitFor (() => {
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '子タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
.toHaveLength (1)
})
})
it ('does not advance host post while video length is unknown', async () => { it ('does not advance host post while video length is unknown', async () => {
api.apiPut.mockImplementation ((path: string) => { api.apiPut.mockImplementation ((path: string) => {
switch (path) switch (path)
+62 -27
ファイルの表示
@@ -15,6 +15,12 @@ import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { fetchPost } from '@/lib/posts' import { fetchPost } from '@/lib/posts'
import {
getClientTheatreLayoutMode,
getClientTheatreTagFlow,
setClientTheatreLayoutMode,
setClientTheatreTagFlow,
} from '@/lib/settings'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils' import { cn, dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -50,9 +56,6 @@ const INITIAL_THEATRE_INFO: TheatreInfo =
const INITIAL_WEIGHTS: TheatrePostSelectionWeights = const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
{ tagPenalties: [], lightestPosts: [], heaviestPosts: [] } { tagPenalties: [], lightestPosts: [], heaviestPosts: [] }
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = { const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列', threeColumns: '3 列',
tagsBottom: '2 列 A 型', tagsBottom: '2 列 A 型',
@@ -102,6 +105,28 @@ const compareTagName = (a: Tag, b: Tag): number =>
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1) a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
const flattenTags = (tags: Tag[]): Tag[] => {
const flattened: Tag[] = []
const seen = new Set<number> ()
const visit = (tag: Tag) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
flattened.push (tag)
for (const child of tag.children ?? [])
visit (child)
}
for (const tag of tags)
visit (tag)
return flattened
}
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => { const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
const grouped: Partial<Record<Category, Tag[]>> = { } const grouped: Partial<Record<Category, Tag[]>> = { }
@@ -118,15 +143,39 @@ const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
} }
const renderReadonlyTagTree = (
tag: Tag,
nestLevel: number,
path: string,
ancestors: Set<number> = new Set<number> (),
): ReactNode[] => {
const key = `${ path }-${ tag.id }`
const nextAncestors = new Set (ancestors)
nextAncestors.add (tag.id)
return [
(
<li key={key} className="text-left leading-tight">
<TagLink tag={tag} nestLevel={nestLevel} withCount={false}/>
</li>),
...((tag.children ?? [])
.filter (child => !(nextAncestors.has (child.id)))
.sort (compareTagName)
.flatMap (child =>
renderReadonlyTagTree (child, nestLevel + 1, key, nextAncestors)))]
}
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{ tags, compact, flow = 'vertical' }) => { { tags, compact, flow = 'vertical' }) => {
const grouped = tagsByCategory (tags) const horizontalGrouped = tagsByCategory (flattenTags (tags))
const verticalGrouped = tagsByCategory (tags)
if (flow === 'horizontal') if (flow === 'horizontal')
{ {
return ( return (
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}> <ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => ( {CATEGORIES.flatMap (cat => horizontalGrouped[cat] ?? []).map (tag => (
<li key={tag.id} className="text-left leading-tight"> <li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/> <TagLink tag={tag} withCount={false}/>
</li>))} </li>))}
@@ -136,7 +185,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{CATEGORIES.map (cat => { {CATEGORIES.map (cat => {
const rows = grouped[cat] ?? [] const rows = verticalGrouped[cat] ?? []
if (rows.length === 0) if (rows.length === 0)
return null return null
@@ -146,10 +195,8 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{CATEGORY_NAMES[cat]} {CATEGORY_NAMES[cat]}
</div> </div>
<ul className={cn ('space-y-1', compact && 'text-sm')}> <ul className={cn ('space-y-1', compact && 'text-sm')}>
{rows.map (tag => ( {rows.flatMap (tag =>
<li key={tag.id} className="text-left leading-tight"> renderReadonlyTagTree (tag, 0, `cat-${ cat }`))}
<TagLink tag={tag} withCount={false}/>
</li>))}
</ul> </ul>
</div>) </div>)
})} })}
@@ -184,32 +231,20 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const [post, setPost] = useState<Post | null> (null) const [post, setPost] = useState<Post | null> (null)
const [videoLength, setVideoLength] = useState (0) const [videoLength, setVideoLength] = useState (0)
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS) const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => { const [layoutMode, setLayoutMode] =
const stored = localStorage.getItem (LAYOUT_STORAGE_KEY) useState<TheatreLayoutMode> (() => getClientTheatreLayoutMode ())
return ( const [tagFlow, setTagFlow] = useState<TagFlow> (() => getClientTheatreTagFlow ())
((['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 { fieldErrors, clearValidationErrors, applyValidationError } = const { fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TheatreCommentField> () useValidationErrors<TheatreCommentField> ()
const changeLayoutMode = (mode: TheatreLayoutMode) => { const changeLayoutMode = (mode: TheatreLayoutMode) => {
setLayoutMode (mode) setLayoutMode (mode)
localStorage.setItem (LAYOUT_STORAGE_KEY, mode) setClientTheatreLayoutMode (mode)
} }
const changeTagFlow = (flow: TagFlow) => { const changeTagFlow = (flow: TagFlow) => {
setTagFlow (flow) setTagFlow (flow)
localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow) setClientTheatreTagFlow (flow)
} }
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
+569 -60
ファイルの表示
@@ -1,43 +1,475 @@
import type { FC } from 'react' import type {
Dispatch,
FC,
KeyboardEvent,
SetStateAction,
} from 'react'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label' import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { useUserSettings } from '@/components/users/UserSettingsProvider'
import InheritDialogue from '@/components/users/InheritDialogue' import InheritDialogue from '@/components/users/InheritDialogue'
import UserCodeDialogue from '@/components/users/UserCodeDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { inputClass } from '@/lib/utils' import {
AUTO_FETCH_OPTIONS,
DEFAULT_USER_SETTINGS,
THEME_OPTIONS,
WIKI_EDITOR_MODE_OPTIONS,
updateUserSettings,
} from '@/lib/settings'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { User } from '@/types' import type { User } from '@/types'
import type { UserSettings } from '@/lib/settings'
type Props = { user: User | null type Props = { user: User | null
setUser: React.Dispatch<React.SetStateAction<User | null>> } setUser: Dispatch<SetStateAction<User | null>> }
type UserFormField = 'name' type UserFormField = 'name'
type SettingsFormField =
| 'theme'
| 'autoFetchTitle'
| 'autoFetchThumbnail'
| 'wikiEditorMode'
type SettingsTab = 'account' | 'theme' | 'keyboard' | 'editing' | 'wiki'
type TabSpec = {
id: SettingsTab
label: string }
type NameFieldErrors = Partial<Record<UserFormField, string[]>>
type SettingsFieldErrors = Partial<Record<SettingsFormField, string[]>>
type SharedSectionProps = {
draftSettings: UserSettings
name: string
nameBaseErrors: string[]
nameFieldErrors: NameFieldErrors
onDraftSettingsChange: Dispatch<SetStateAction<UserSettings>>
onInheritOpen: () => void
onNameChange: (value: string) => void
onSettingsSubmit: () => void
onUserCodeOpen: () => void
onUserSubmit: () => void
settingsBaseErrors: string[]
settingsFieldErrors: SettingsFieldErrors
user: User }
const sectionClassName =
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
const desktopTabRailClassName = [
'hidden md:flex md:justify-center',
].join (' ')
const desktopTabRailInnerClassName = [
'grid w-fit grid-cols-[12rem_minmax(0,36rem)] items-start gap-6',
].join (' ')
const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' },
{ id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' },
{ id: 'editing', label: '編輯支援' },
{ id: 'wiki', label: 'Wiki' },
]
const settingsTabByField: Record<SettingsFormField, SettingsTab> = {
theme: 'theme',
autoFetchTitle: 'editing',
autoFetchThumbnail: 'editing',
wikiEditorMode: 'wiki',
}
const settingsFieldOrder: SettingsFormField[] = [
'theme',
'autoFetchTitle',
'autoFetchThumbnail',
'wikiEditorMode',
]
const themeLabel: Record<UserSettings['theme'], string> = {
system: 'システム設定に従う',
light: 'ライト・モード',
dark: 'ダーク・モード',
}
const autoFetchLabel: Record<UserSettings['autoFetchTitle'], string> = {
auto: '自動',
manual: '手動',
off: '無効',
}
const wikiEditorModeLabel: Record<UserSettings['wikiEditorMode'], string> = {
split: '左右表示',
write: '本文のみ',
preview: 'プレビューのみ',
}
const keyboardShortcutRows = [
{ action: '投稿詳細を開く', key: 'Enter', scope: '一覧・検索結果', conflict: 'なし' },
{ action: '候補タグを選ぶ', key: '↑ / ↓ / Enter', scope: 'タグ入力', conflict: 'なし' },
{ action: '候補タグを閉ぢる', key: 'Escape', scope: 'タグ入力', conflict: 'なし' },
]
const themeColorTargets = [
'背景色',
'文字色',
'枠線色',
'リンク色',
'強調色',
'通常タグ',
'キャラタグ',
'素材タグ',
'Nico タグ',
'廃止タグ',
'Wiki ありタグ',
'Wiki 未作成タグ',
'選択中タグ',
'ホバー中タグ',
]
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 AccountSection: FC<SharedSectionProps> = ({
name,
nameBaseErrors,
nameFieldErrors,
onInheritOpen,
onNameChange,
onUserCodeOpen,
onUserSubmit,
user,
}) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={nameBaseErrors}/>
<FormField label="表示名" messages={nameFieldErrors.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> = ({
draftSettings,
onDraftSettingsChange,
onSettingsSubmit,
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={draftSettings.theme}
onChange={ev => onDraftSettingsChange (current => ({
...current,
theme: ev.target.value as UserSettings['theme'],
}))}>
{THEME_OPTIONS.map (value => (
<option key={value} value={value}>
{themeLabel[value]}
</option>))}
</select>)}
</FormField>
<div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm">
<p className="font-medium"></p>
<p>
`themes` `user_themes`
</p>
<p></p>
<div className="flex flex-wrap gap-2">
{themeColorTargets.map (target => (
<span
key={target}
className="rounded-full border border-border/70 px-2 py-1">
{target}
</span>))}
</div>
</div>
<Button type="button" onClick={onSettingsSubmit}>
</Button>
</section>)
const KeyboardSection: FC = () => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm">
<p>
`action / key / scope / conflict`
key bind
</p>
</div>
<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 EditingSection: FC<SharedSectionProps> = ({
draftSettings,
onDraftSettingsChange,
onSettingsSubmit,
settingsBaseErrors,
settingsFieldErrors,
}) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={settingsBaseErrors}/>
<FormField label="タイトル自動取得" messages={settingsFieldErrors.autoFetchTitle}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.autoFetchTitle?.length),
)}
value={draftSettings.autoFetchTitle}
onChange={ev => onDraftSettingsChange (current => ({
...current,
autoFetchTitle: ev.target.value as UserSettings['autoFetchTitle'],
}))}>
{AUTO_FETCH_OPTIONS.map (value => (
<option key={value} value={value}>
{autoFetchLabel[value]}
</option>))}
</select>)}
</FormField>
<FormField
label="サムネール自動取得"
messages={settingsFieldErrors.autoFetchThumbnail}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.autoFetchThumbnail?.length),
)}
value={draftSettings.autoFetchThumbnail}
onChange={ev => onDraftSettingsChange (current => ({
...current,
autoFetchThumbnail:
ev.target.value as UserSettings['autoFetchThumbnail'],
}))}>
{AUTO_FETCH_OPTIONS.map (value => (
<option key={value} value={value}>
{autoFetchLabel[value]}
</option>))}
</select>)}
</FormField>
<Button type="button" onClick={onSettingsSubmit}>
</Button>
</section>)
const WikiSection: FC<SharedSectionProps> = ({
draftSettings,
onDraftSettingsChange,
onSettingsSubmit,
settingsBaseErrors,
settingsFieldErrors,
}) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold">Wiki</h2>
<FieldError messages={settingsBaseErrors}/>
<FormField label="エディタ表示" messages={settingsFieldErrors.wikiEditorMode}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.wikiEditorMode?.length),
)}
value={draftSettings.wikiEditorMode}
onChange={ev => onDraftSettingsChange (current => ({
...current,
wikiEditorMode: ev.target.value as UserSettings['wikiEditorMode'],
}))}>
{WIKI_EDITOR_MODE_OPTIONS.map (value => (
<option key={value} value={value}>
{wikiEditorModeLabel[value]}
</option>))}
</select>)}
</FormField>
<Button type="button" onClick={onSettingsSubmit}>
</Button>
</section>)
const SettingPage: FC<Props> = ({ user, setUser }) => { const SettingPage: FC<Props> = ({ user, setUser }) => {
const location = useLocation ()
const navigate = useNavigate ()
const [draftSettings, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
const [name, setName] = useState ('') const [name, setName] = useState ('')
const [userCodeVsbl, setUserCodeVsbl] = useState (false) const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const [inheritVsbl, setInheritVsbl] = useState (false) const [inheritVsbl, setInheritVsbl] = useState (false)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { error, loaded, settings, setSettings } = useUserSettings ()
useValidationErrors<UserFormField> () const {
baseErrors: nameBaseErrors,
fieldErrors: nameFieldErrors,
clearValidationErrors: clearNameValidationErrors,
applyValidationError: applyNameValidationError,
} = useValidationErrors<UserFormField> ()
const {
baseErrors: settingsBaseErrors,
fieldErrors: settingsFieldErrors,
clearValidationErrors: clearSettingsValidationErrors,
applyValidationError: applySettingsValidationError,
} = useValidationErrors<SettingsFormField> ()
const handleSubmit = async () => { const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
const setActiveTab = useCallback ((tab: SettingsTab) => {
const params = new URLSearchParams (location.search)
params.set ('tab', tab)
navigate (
`${ location.pathname }${ params.toString () ? `?${ params.toString () }` : '' }`,
{ replace: true },
)
}, [location.pathname, location.search, navigate])
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (() => ({
account: Boolean (nameFieldErrors.name?.length),
theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: false,
editing: (
Boolean (settingsFieldErrors.autoFetchTitle?.length)
|| Boolean (settingsFieldErrors.autoFetchThumbnail?.length)
),
wiki: Boolean (settingsFieldErrors.wikiEditorMode?.length),
}), [nameFieldErrors.name, settingsFieldErrors])
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const currentIndex = tabs.findIndex (tab => tab.id === activeTab)
switch (event.key)
{
case 'ArrowRight':
case 'ArrowDown':
event.preventDefault ()
setActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
break
case 'ArrowLeft':
case 'ArrowUp':
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)) if (!(user))
return return
clearValidationErrors () clearNameValidationErrors ()
const formData = new FormData const formData = new FormData
formData.append ('name', name) formData.append ('name', name)
@@ -47,13 +479,35 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const data = await apiPut<User> ( const data = await apiPut<User> (
`/users/${ user.id }`, formData, `/users/${ user.id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }) { headers: { 'Content-Type': 'multipart/form-data' } })
setUser (user => ({ ...user, ...data })) setUser (currentUser => ({ ...currentUser, ...data }))
toast ({ title: '設定を更新しました.' }) toast ({ title: '表示名を更新しました.' })
} }
catch (e) catch (submitError)
{ {
applyValidationError (e) applyNameValidationError (submitError)
toast ({ title: 'しっぱい……' }) toast ({ title: '表示名を更新できませんでした.' })
}
}
const handleSettingsSubmit = async () => {
clearSettingsValidationErrors ()
try
{
const data = await updateUserSettings ({
theme: draftSettings.theme,
auto_fetch_title: draftSettings.autoFetchTitle,
auto_fetch_thumbnail: draftSettings.autoFetchThumbnail,
wiki_editor_mode: draftSettings.wikiEditorMode,
})
setDraftSettings (data)
setSettings (data)
toast ({ title: '設定を保存しました.' })
}
catch (submitError)
{
applySettingsValidationError (submitError)
toast ({ title: '設定を保存できませんでした.' })
} }
} }
@@ -64,6 +518,39 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setName (user.name ?? '') setName (user.name ?? '')
}, [user]) }, [user])
useEffect (() => {
setDraftSettings (settings)
}, [settings])
useEffect (() => {
if (nameFieldErrors.name?.length)
setActiveTab ('account')
}, [nameFieldErrors.name, setActiveTab])
useEffect (() => {
const fieldWithError = settingsFieldOrder.find (
field => (settingsFieldErrors[field]?.length ?? 0) > 0,
)
if (fieldWithError)
setActiveTab (settingsTabByField[fieldWithError])
}, [setActiveTab, settingsFieldErrors])
const sectionProps: SharedSectionProps | null = user ? {
draftSettings,
name,
nameBaseErrors,
nameFieldErrors,
onDraftSettingsChange: setDraftSettings,
onInheritOpen: () => setInheritVsbl (true),
onNameChange: setName,
onSettingsSubmit: handleSettingsSubmit,
onUserCodeOpen: () => setUserCodeVsbl (true),
onUserSubmit: handleUserSubmit,
settingsBaseErrors,
settingsFieldErrors,
user,
} : null
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
@@ -71,62 +558,84 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<title> | {SITE_TITLE}</title> <title> | {SITE_TITLE}</title>
</Helmet> </Helmet>
<Form> <div className="space-y-4 p-4">
<PageTitle></PageTitle> <div className="mx-auto max-w-xl space-y-4">
<PageTitle></PageTitle>
<FieldError messages={error ? [error] : []}/>
</div>
{user ? ( {user && loaded ? (
<> <>
<FieldError messages={baseErrors}/> <div className="mx-auto max-w-xl space-y-6 md:hidden">
{sectionProps && (
{/* 名前 */}
<FormField label="表示名" messages={fieldErrors.name}>
{({ describedBy, invalid }) => (
<> <>
<input type="text" <AccountSection {...sectionProps}/>
aria-describedby={describedBy} <ThemeSection {...sectionProps}/>
aria-invalid={invalid} <KeyboardSection/>
className={inputClass (invalid)} <EditingSection {...sectionProps}/>
value={name} <WikiSection {...sectionProps}/>
placeholder="名もなきニジラー"
onChange={ev => setName (ev.target.value)}/>
{(user && !(user.name)) && (
<p className="mt-1 text-sm text-red-500">
30 !!!!
</p>)}
</>)} </>)}
</FormField> </div>
{/* 送信 */} <div className={desktopTabRailClassName}>
<Button onClick={handleSubmit} <div className={desktopTabRailInnerClassName}>
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> <div
role="tablist"
</Button> 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>
{/* 引継ぎ */} <div className="w-[36rem] max-w-full">
<div> {sectionProps && (
<Label></Label> <div
<Button onClick={() => setUserCodeVsbl (true)} id={tabPanelId (activeTab)}
className="px-4 py-2 bg-gray-600 text-white rounded disabled:bg-gray-400" role="tabpanel"
disabled={!(user)}> aria-labelledby={tabButtonId (activeTab)}>
{activeTab === 'account' && <AccountSection {...sectionProps}/>}
</Button> {activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
<Button onClick={() => setInheritVsbl (true)} {activeTab === 'keyboard' && <KeyboardSection/>}
className="ml-2 px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400" {activeTab === 'editing' && <EditingSection {...sectionProps}/>}
disabled={!(user)}> {activeTab === 'wiki' && <WikiSection {...sectionProps}/>}
</div>)}
</Button> </div>
</div>
</div> </div>
</>) : 'Loading...'} </>) : 'Loading...'}
</Form> </div>
<UserCodeDialogue visible={userCodeVsbl} <UserCodeDialogue
onVisibleChange={setUserCodeVsbl} visible={userCodeVsbl}
user={user} onVisibleChange={setUserCodeVsbl}
setUser={setUser}/> user={user}
setUser={setUser}/>
<InheritDialogue visible={inheritVsbl} <InheritDialogue
onVisibleChange={setInheritVsbl} visible={inheritVsbl}
setUser={setUser}/> onVisibleChange={setInheritVsbl}
setUser={setUser}/>
</MainArea>) </MainArea>)
} }
+24
ファイルの表示
@@ -8,6 +8,7 @@ import { useParams, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { useUserSettings } from '@/components/users/UserSettingsProvider'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPut } from '@/lib/api' import { apiGet, apiPut } from '@/lib/api'
@@ -25,6 +26,27 @@ import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt const mdParser = new MarkdownIt
type EditorView = {
menu: boolean
md: boolean
html: boolean }
const editorView = (
mode: 'split' | 'write' | 'preview',
): EditorView => {
switch (mode)
{
case 'write':
return { menu: true, md: true, html: false }
case 'preview':
return { menu: true, md: false, html: true }
default:
return { menu: true, md: true, html: true }
}
}
type Props = { user: User | null } type Props = { user: User | null }
type WikiFormField = 'title' | 'body' type WikiFormField = 'title' | 'body'
@@ -32,6 +54,7 @@ type WikiFormField = 'title' | 'body'
const WikiEditPage: FC<Props> = ({ user }) => { const WikiEditPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const { settings } = useUserSettings ()
const { id } = useParams () const { id } = useParams ()
@@ -114,6 +137,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{() => ( {() => (
<MdEditor value={body} <MdEditor value={body}
style={{ height: '500px' }} style={{ height: '500px' }}
config={{ view: editorView (settings.wikiEditorMode) }}
renderHTML={text => mdParser.render (text)} renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)} onChange={({ text }) => setBody (text)}/>)}
</FormField> </FormField>
+24
ファイルの表示
@@ -9,6 +9,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { useUserSettings } from '@/components/users/UserSettingsProvider'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
@@ -23,6 +24,27 @@ import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt const mdParser = new MarkdownIt
type EditorView = {
menu: boolean
md: boolean
html: boolean }
const editorView = (
mode: 'split' | 'write' | 'preview',
): EditorView => {
switch (mode)
{
case 'write':
return { menu: true, md: true, html: false }
case 'preview':
return { menu: true, md: false, html: true }
default:
return { menu: true, md: true, html: true }
}
}
type Props = { user: User | null } type Props = { user: User | null }
type WikiFormField = 'title' | 'body' type WikiFormField = 'title' | 'body'
@@ -30,6 +52,7 @@ type WikiFormField = 'title' | 'body'
const WikiNewPage: FC<Props> = ({ user }) => { const WikiNewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const { settings } = useUserSettings ()
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
@@ -92,6 +115,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{() => ( {() => (
<MdEditor value={body} <MdEditor value={body}
style={{ height: '500px' }} style={{ height: '500px' }}
config={{ view: editorView (settings.wikiEditorMode) }}
renderHTML={text => mdParser.render (text)} renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)} onChange={({ text }) => setBody (text)}/>)}
</FormField> </FormField>
+5 -2
ファイルの表示
@@ -1,6 +1,6 @@
import type { Material, import type { Material,
Post, Post,
Tag, TagWithSections,
Theatre, Theatre,
TheatreComment, TheatreComment,
TheatreInfo, TheatreInfo,
@@ -9,7 +9,7 @@ import type { Material,
User, User,
WikiPage } from '@/types' WikiPage } from '@/types'
export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({ export const buildTag = (overrides: Partial<TagWithSections> = {}): TagWithSections => ({
id: 1, id: 1,
name: 'テストタグ', name: 'テストタグ',
category: 'general', category: 'general',
@@ -23,6 +23,8 @@ export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
materialId: null, materialId: null,
hasDeerjikists: false, hasDeerjikists: false,
matchedAlias: null, matchedAlias: null,
sections: [],
children: [],
...overrides, ...overrides,
}) })
@@ -33,6 +35,7 @@ export const buildPost = (overrides: Partial<Post> = {}): Post => ({
title: 'テスト投稿', title: 'テスト投稿',
thumbnail: 'https://example.com/thumb.jpg', thumbnail: 'https://example.com/thumb.jpg',
thumbnailBase: null, thumbnailBase: null,
videoMs: null,
tags: [buildTag ()], tags: [buildTag ()],
parentPosts: [], parentPosts: [],
childPosts: [], childPosts: [],
+7 -1
ファイルの表示
@@ -288,11 +288,12 @@ export type Post = {
title: string | null title: string | null
thumbnail: string | null thumbnail: string | null
thumbnailBase: string | null thumbnailBase: string | null
videoMs: number | null
postSimilarityEdges?: { postSimilarityEdges?: {
targetPostId: number targetPostId: number
cos: number cos: number
}[] }[]
tags: Tag[] tags: TagWithSections[]
parentPosts?: Post[] parentPosts?: Post[]
childPosts?: Post[] childPosts?: Post[]
siblingPosts?: Record<`${ number }`, Post[]> siblingPosts?: Record<`${ number }`, Post[]>
@@ -320,6 +321,7 @@ export type PostVersion = {
url: { current: string; prev: string | null } url: { current: string; prev: string | null }
thumbnail: { current: string | null; prev: string | null } thumbnail: { current: string | null; prev: string | null }
thumbnailBase: { current: string | null; prev: string | null } thumbnailBase: { current: string | null; prev: string | null }
videoMs: { current: number | null; prev: number | null }
tags: { name: string tags: { name: string
type: 'context' | 'added' | 'removed' }[] type: 'context' | 'added' | 'removed' }[]
parentPosts: { id: number parentPosts: { id: number
@@ -369,6 +371,10 @@ export type TagVersion = {
createdAt: string createdAt: string
createdByUser: { id: number; name: string | null } | null } createdByUser: { id: number; name: string | null } | null }
export type TagWithSections = Omit<Tag, 'children'> & { sections: { beginMs: number
endMs: number | null }[]
children: TagWithSections[] }
export type Theatre = { export type Theatre = {
id: number id: number
name: string | null name: string | null