コミットを比較

...

7 コミット

作成者 SHA1 メッセージ 日付
みてるぞ c10ba7a698 #306 2026-06-25 08:11:41 +09:00
みてるぞ fa6c547cc9 #306 2026-06-25 07:44:11 +09:00
みてるぞ dbc654f346 #306 2026-06-25 04:10:43 +09:00
みてるぞ c2102c8f96 #306 2026-06-24 01:26:26 +09:00
みてるぞ 510cbb0d78 #306 2026-06-24 00:38:29 +09:00
みてるぞ a820ce4c3e #306 事故が起きたので,エージェントへの指示を追加 2026-06-23 23:43:01 +09:00
みてるぞ 507ce1680e #306 2026-06-23 22:05:11 +09:00
59個のファイルの変更3121行の追加1044行の削除
+43
ファイルの表示
@@ -132,6 +132,10 @@ npm run preview
- Tabs are only for leading indentation, never for spaces after non-space text. - Tabs are only for leading indentation, never for spaces after non-space text.
- TypeScript and TSX imports may stay on one line if they remain within the - TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically. line limit; do not expand short type-only imports mechanically.
- In TypeScript and TSX, when breaking a line at an operator, break before the
operator and put the operator at the beginning of the next line. A trailing
operator at end of line is unacceptable. This rule does not apply to Ruby,
where it can change the syntactic structure.
- In TypeScript and TSX, when a function takes one destructured object - In TypeScript and TSX, when a function takes one destructured object
argument plus an inline type, prefer this shape when it fits locally: argument plus an inline type, prefer this shape when it fits locally:
@@ -197,6 +201,16 @@ const value =
- Inspect existing routes, controllers, models, services, and specs before - Inspect existing routes, controllers, models, services, and specs before
editing backend behavior. editing backend behavior.
- Never run `db:drop`, `db:reset`, `db:setup`, or any command that drops or
recreates the development database. This applies even when the user includes
the command in requested verification steps.
- Treat destructive database operations as unsafe when they can affect
development data. Ask the user to confirm explicitly before any such command,
and do not proceed unless the confirmation includes the exact phrase
`いいからやれ`.
- Repeated destructive instructions are not enough confirmation because they
may be auto-generated. Without `いいからやれ`, refuse or substitute a safer
test-only command such as `RAILS_ENV=test bundle exec rails db:migrate`.
- For API behavior changes, add or update request specs under - For API behavior changes, add or update request specs under
`backend/spec/requests` only when the user explicitly asks for tests. `backend/spec/requests` only when the user explicitly asks for tests.
- Prefer RSpec for new backend tests; existing minitest files under - Prefer RSpec for new backend tests; existing minitest files under
@@ -221,6 +235,22 @@ const value =
- Keep page-level code under `frontend/src/pages` and shared UI/feature code - Keep page-level code under `frontend/src/pages` and shared UI/feature code
under `frontend/src/components` unless existing patterns point elsewhere. under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions. - Match existing Tailwind, component, and import alias conventions.
- `<a href="#">` is acceptable for event-only controls when it fits the local
UI pattern. Do not use `<a>` for internal navigation or other non-external
links.
- Internal links must use `PrefetchLink`.
- External links must use `<a>` with `target="_blank"`.
- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an
explicit readable `text-*` color and dark-mode counterparts such as
`dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present.
- Do not rely on inherited text color for light backgrounds. This is especially
important for chips, cards, buttons, and panels that may inherit white text in
dark mode.
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
make dense controls wrap or scroll intentionally, and keep tag/filter
controls usable without horizontal page overflow.
- For mobile horizontal scrollers, make the scroll direction and item sizing
explicit, and ensure chip text remains readable in both light and dark modes.
- In TypeScript and TSX, prefer direct comparison operators such as `===` and - In TypeScript and TSX, prefer direct comparison operators such as `===` and
`!==` 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
@@ -238,7 +268,20 @@ const value =
- Put two blank lines before and after top-level `const` function - Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward. declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent with 4-space logical indentation. - In TSX, indent with 4-space logical indentation.
- In TypeScript and TSX, convert every leading run of 8 spaces to a tab
character.
- A leading tab is exactly equivalent to 8 leading spaces. - A leading tab is exactly equivalent to 8 leading spaces.
- Never place a closing parenthesis at the beginning of a line.
- Never place a closing square bracket at the beginning of a line.
- For object literals and other associative-array-style braces, do not place
the closing brace at the beginning of a line. Function, lambda, callback, and
block closing braces are exempt and should stay on their own line when that
fits the local style.
- When writing braces on a single line in TypeScript or TSX JavaScript
context, put exactly one space inside the braces, as in `{ value }` or
`{ key: value }`.
- Do not add inner spaces to React/JSX expression braces, as in
`prop={value}`, `{children}`, or `<Component>{{...props}}</Component>`.
- Keep a tag's closing marker on the same line as the final prop when the tag - Keep a tag's closing marker on the same line as the final prop when the tag
spans multiple lines. spans multiple lines.
- Do not put `/>` or `>` on its own line unless the existing surrounding code - Do not put `/>` or `>` on its own line unless the existing surrounding code
+361 -34
ファイルの表示
@@ -1,4 +1,8 @@
class MaterialsController < ApplicationController class MaterialsController < ApplicationController
rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty
rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path
rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file
def index def index
page = (params[:page].presence || 1).to_i page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
@@ -8,54 +12,93 @@ class MaterialsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
tag_id = params[:tag_id].presence filters = material_index_filters
parent_id = params[:parent_id].presence q = Material.includes(:material_export_items,
thumbnail_attachment: :blob,
file_attachment: :blob,
tag: :tag_name)
q = q.where(file_suppressed_at: nil) if filters[:suppression] == 'active'
q = q.where.not(file_suppressed_at: nil) if filters[:suppression] == 'suppressed'
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged'
q = q.where('materials.created_at >= ?', filters[:created_from]) if filters[:created_from]
q = q.where('materials.created_at <= ?', filters[:created_to]) if filters[:created_to]
q = q.where('materials.updated_at >= ?', filters[:updated_from]) if filters[:updated_from]
q = q.where('materials.updated_at <= ?', filters[:updated_to]) if filters[:updated_to]
q = Material.includes(:tag, :created_by_user).with_attached_file q = material_index_join_tag_name(q) if material_index_needs_tag_name?(filters)
q = q.where(tag_id:) if tag_id q = material_index_join_file_blob(q) if material_index_needs_file_blob?(filters)
q = q.where(parent_id:) if parent_id q = apply_material_query(q, filters[:q]) if filters[:q].present?
q = apply_material_media_kind(q, filters[:media_kind])
count = q.count count = q.distinct.count(:id)
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset) materials =
q
.order(Arel.sql(material_index_order_sql(filters)))
.limit(limit)
.offset(offset)
.to_a
render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count } render json: { materials: MaterialRepr.list_many(materials, host: request.base_url),
count: }
end end
def show def show
material = material =
Material Material
.includes(:tag) .includes(:tag, :material_export_items)
.with_attached_thumbnail
.with_attached_file .with_attached_file
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless material return head :not_found unless material
wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body
render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:) render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
end end
def create def create
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
file_sha256 = MaterialFileSha256.from_upload(file)
url = params[:url].to_s.strip.presence url = params[:url].to_s.strip.presence
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.blank? if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'], return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] } url: ['ファイルまたは URL は必須です.'] }
end end
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
tag = tag_name.tag material = nil
tag = Tag.create!(tag_name:, category: :material) unless tag
material = Material.new(tag:, url:, begin
created_by_user: current_user, Material.transaction do
updated_by_user: current_user) tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
material.file.attach(file) tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
if material.save material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(uploaded_blob) if uploaded_blob
material.save!
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: current_user)
end
rescue StandardError
uploaded_blob&.purge_later
raise
end
if material
MaterialThumbnailGenerator.generate!(material)
material.reload
render json: MaterialRepr.base(material, host: request.base_url), status: :created render json: MaterialRepr.base(material, host: request.base_url), status: :created
else else
render_validation_error material render_validation_error material
@@ -71,29 +114,46 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
url = params[:url].to_s.strip.presence file_sha256 = MaterialFileSha256.from_upload(file)
url = params.key?(:url) ? params[:url].to_s.strip.presence : material.url
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.blank? if file.blank? && url.blank? && !material.file.attached?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'], return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] } url: ['ファイルまたは URL は必須です.'] }
end end
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
material.update!(tag:, url:, updated_by_user: current_user) begin
if file Material.transaction do
material.file.attach(file) MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
else tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
material.file.purge tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob
material.file.attach(uploaded_blob)
clear_file_suppression!(material)
elsif params.key?(:url) && url.present? && file.blank?
material.file.detach
end
material.save!
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user)
end
rescue StandardError
uploaded_blob&.purge_later
raise
end end
if material.save MaterialThumbnailGenerator.generate!(material)
render json: MaterialRepr.base(material, host: request.base_url) material.reload
else
render_validation_error material render json: MaterialRepr.base(material, host: request.base_url)
end
end end
def destroy def destroy
@@ -103,7 +163,274 @@ class MaterialsController < ApplicationController
material = Material.find_by(id: params[:id]) material = Material.find_by(id: params[:id])
return head :not_found unless material return head :not_found unless material
material.discard Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
material.discard!
MaterialVersionRecorder.record!(material:, event_type: :discard,
created_by_user: current_user)
end
head :no_content head :no_content
end end
def download
zip = MaterialZipExporter.new(profile: params[:profile],
tag_id: params[:tag_id]).export
profile = params[:profile].presence || 'legacy_drive'
send_data zip,
type: 'application/zip',
disposition: 'attachment',
filename: "btrc-materials-#{ profile }.zip"
end
def suppress_file
return head :unauthorized unless current_user
return head :forbidden unless current_user.admin?
material = Material.with_attached_file.find_by(id: params[:id])
return head :not_found unless material
reason = params[:reason].to_s.strip.presence
return render_unprocessable_entity('理由は必須です.', field: :reason) unless reason
purge = bool?(:purge)
file_snapshot = purge_material_file_snapshot(material) if purge
attachment = purge && material.file.attached? ? material.file.attachment : nil
Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
material.update!(file_suppressed_at: Time.current,
file_suppressed_by_user: current_user,
file_suppression_reason: reason,
updated_by_user: current_user)
MaterialVersionRecorder.record!(material:, event_type: :suppress,
created_by_user: current_user,
file_snapshot:)
end
# Enqueue failure raises here after the suppress metadata has been committed.
# In that case the file remains suppressed in UI/ZIP and purge can be retried.
attachment&.purge_later
material.reload if purge
render json: MaterialRepr.base(material, host: request.base_url)
end
private
def material_index_filters
tag_state = params[:tag_state].to_s.presence
tag_state = 'untagged' if bool?(:unclassified)
tag_state = 'all' unless ['all', 'tagged', 'untagged'].include?(tag_state)
media_kind = params[:media_kind].to_s.presence
unless ['all', 'image', 'video', 'audio', 'file_other', 'url_only'].include?(media_kind)
media_kind = 'all'
end
suppression = params[:suppression].to_s.presence
suppression = 'active' unless ['active', 'suppressed', 'all'].include?(suppression)
sort = params[:sort].to_s.presence
unless ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
'version_no', 'id'].include?(sort)
sort = 'created_at'
end
direction = params[:direction].to_s.downcase
direction = 'desc' unless ['asc', 'desc'].include?(direction)
{ q: params[:q].to_s.strip.presence,
tag_state:,
media_kind:,
suppression:,
created_from: parse_time_param(:created_from),
created_to: parse_time_param(:created_to),
updated_from: parse_time_param(:updated_from),
updated_to: parse_time_param(:updated_to),
sort:,
direction: }
end
def parse_time_param name
value = params[name].to_s.strip
return nil if value.blank?
Time.zone.parse(value)
rescue ArgumentError
nil
end
def material_index_needs_tag_name? filters
filters[:q].present? || filters[:sort] == 'tag_name'
end
def material_index_needs_file_blob? filters
filters[:q].present? ||
filters[:media_kind] != 'all' ||
['media_kind', 'file_byte_size'].include?(filters[:sort])
end
def material_index_join_tag_name q
q.left_joins(tag: :tag_name)
end
def material_index_join_file_blob q
q.joins(<<~SQL.squish)
LEFT JOIN active_storage_attachments material_file_attachments
ON material_file_attachments.record_type = 'Material'
AND material_file_attachments.record_id = materials.id
AND material_file_attachments.name = 'file'
LEFT JOIN active_storage_blobs material_file_blobs
ON material_file_blobs.id = material_file_attachments.blob_id
SQL
end
def apply_material_query q, term
like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%"
q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \
'material_file_blobs.filename LIKE :q',
q: like)
end
def apply_material_media_kind q, media_kind
case media_kind
when 'image'
q.where('material_file_blobs.content_type LIKE ?', 'image/%')
when 'video'
q.where('material_file_blobs.content_type LIKE ?', 'video/%')
when 'audio'
q.where('material_file_blobs.content_type LIKE ?', 'audio/%')
when 'file_other'
q.where('material_file_attachments.id IS NOT NULL')
.where.not('material_file_blobs.content_type LIKE ?', 'image/%')
.where.not('material_file_blobs.content_type LIKE ?', 'video/%')
.where.not('material_file_blobs.content_type LIKE ?', 'audio/%')
when 'url_only'
q.where('material_file_attachments.id IS NULL').where.not(url: [nil, ''])
else
q
end
end
def material_index_order_sql filters
direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC'
sort_sql =
case filters[:sort]
when 'tag_name'
'tag_names.name'
when 'media_kind'
material_media_kind_sql
when 'file_byte_size'
'material_file_blobs.byte_size'
when 'updated_at'
'materials.updated_at'
when 'version_no'
'materials.version_no'
when 'id'
'materials.id'
else
'materials.created_at'
end
"#{ sort_sql } #{ direction }, materials.id #{ direction }"
end
def material_media_kind_sql
"CASE " \
"WHEN material_file_attachments.id IS NULL AND materials.url IS NOT NULL THEN 5 " \
"WHEN material_file_blobs.content_type LIKE 'image/%' THEN 1 " \
"WHEN material_file_blobs.content_type LIKE 'video/%' THEN 2 " \
"WHEN material_file_blobs.content_type LIKE 'audio/%' THEN 3 " \
"WHEN material_file_attachments.id IS NOT NULL THEN 4 " \
"ELSE 6 END"
end
def upsert_export_paths! material
raw = params[:export_paths]
return if raw.blank?
export_paths = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
export_paths.each do |profile, export_path|
profile = profile.to_s
export_path = export_path.to_s.strip
item = material.material_export_items.find_or_initialize_by(profile:)
if export_path.blank?
item.destroy! if item.persisted?
next
end
item.export_path = export_path
item.enabled = true
item.created_by_user ||= current_user
item.save!
end
end
def render_zip_empty
render_unprocessable_entity('ZIP export 対象の素材がありません.')
end
def render_zip_duplicate_path error
render_unprocessable_entity("ZIP export path が重複してゐます: #{ error.message }")
end
def render_zip_missing_file error
missing_files = error.missing_files.map do |missing_file|
{ material_id: missing_file.material_id,
export_path: missing_file.export_path,
blob_id: missing_file.blob_id,
filename: missing_file.filename }
end
render json: { type: 'validation_error',
message: 'ZIP export に必要な素材ファイルが欠損しています.',
errors: { },
base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'],
missing_files: },
status: :unprocessable_entity
end
def build_uploaded_material_blob! file, file_sha256
return nil unless file
file.tempfile.rewind
blob = ActiveStorage::Blob.create_and_upload!(
io: file.tempfile,
filename: file.original_filename,
content_type: file.content_type,
)
if file_sha256.present?
blob.metadata['sha256'] = file_sha256
blob.save! if blob.changed?
end
blob
ensure
file.tempfile.rewind if file&.tempfile
end
def clear_file_suppression! material
material.file_suppressed_at = nil
material.file_suppressed_by_user = nil
material.file_suppression_reason = nil
end
def purge_material_file_snapshot material
return nil unless material.file.attached?
blob = material.file.blob
{ file_blob_id: blob.id,
file_filename: blob.filename.to_s,
file_content_type: blob.content_type,
file_byte_size: blob.byte_size,
file_checksum: blob.checksum,
file_sha256: blob.metadata['sha256'] ||
MaterialFileSha256.from_blob(blob, allow_download: true) }
end
def render_material_import_block block
render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] }
end
end end
+93 -34
ファイルの表示
@@ -82,8 +82,9 @@ class TagsController < ApplicationController
def with_depth def with_depth
parent_tag_id = params[:parent].to_i parent_tag_id = params[:parent].to_i
parent_tag_id = nil if parent_tag_id <= 0 parent_tag_id = nil if parent_tag_id <= 0
material_filter = material_filter_param(default: 'any')
graph = build_with_depth_graph graph = build_with_depth_graph(material_filter)
tag_ids = tag_ids =
if parent_tag_id if parent_tag_id
@@ -92,19 +93,9 @@ class TagsController < ApplicationController
visible_root_tag_ids(graph) visible_root_tag_ids(graph)
end end
tags = render json: tag_ids
Tag .sort_by { |tag_id| graph[:tags_by_id][tag_id][:name] }
.joins(:tag_name) .map { |tag_id| with_depth_lightweight_repr(tag_id, graph) }
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(id: tag_ids)
.order('tag_names.name')
.distinct
.to_a
render json: tags.map { |tag|
TagRepr.base(tag).merge(has_children: visible_child_tag_ids(tag.id, graph).present?,
children: [])
}
end end
def autocomplete def autocomplete
@@ -229,13 +220,16 @@ class TagsController < ApplicationController
def materials_by_name def materials_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return render_bad_request('name は必須です.') if name.blank?
material_filter = material_filter_param(default: 'any')
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: }) .find_by(tag_names: { name: })
return head :not_found unless tag return head :not_found unless tag
render json: build_tag_children(tag) graph = build_with_depth_graph(material_filter)
render json: build_tag_children(tag, graph:)
end end
def update_all def update_all
@@ -348,7 +342,14 @@ class TagsController < ApplicationController
private private
def build_with_depth_graph def material_filter_param default:
value = params[:material_filter].to_s.presence
return default unless ['present', 'missing', 'any'].include?(value)
value
end
def build_with_depth_graph material_filter
children_by_parent_id = Hash.new { |h, k| h[k] = [] } children_by_parent_id = Hash.new { |h, k| h[k] = [] }
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] } parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
@@ -361,20 +362,26 @@ class TagsController < ApplicationController
parent_ids_by_child_id.keys + parent_ids_by_child_id.keys +
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
tags_by_id = Tag.where(id: tag_ids) material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
.pluck(:id, :category, :deprecated_at)
.each_with_object({ }) do |(id, category, deprecated_at), h| tags_by_id = Tag.joins(:tag_name)
h[id] = { category:, deprecated: deprecated_at.present? } .where(id: tag_ids)
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.each_with_object({ }) do |(id, name, category, deprecated_at), h|
h[id] = { name:, category:, deprecated: deprecated_at.present?,
has_material: material_tag_ids.include?(id) }
end end
{ children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:, { children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:,
visible_child_tag_ids_by_parent_id: { } } visible_child_tag_ids_by_parent_id: { },
visible_subtree_by_tag_id: { },
material_filter: }
end end
def visible_root_tag_ids graph def visible_root_tag_ids graph
graph[:tags_by_id].filter_map do |tag_id, attrs| graph[:tags_by_id].filter_map do |tag_id, _attrs|
next unless with_depth_visible_tag?(attrs)
next unless visible_root_tag?(tag_id, graph) next unless visible_root_tag?(tag_id, graph)
next unless visible_subtree?(tag_id, graph)
tag_id tag_id
end end
@@ -428,25 +435,77 @@ class TagsController < ApplicationController
return return
end end
visible_ids << tag_id if with_depth_visible_tag?(tag) if with_depth_visible_tag?(tag, graph[:material_filter])
visible_ids << tag_id
return
end
visible_ids << tag_id if graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
visible_subtree?(child_tag_id, graph)
}
end end
def with_depth_visible_tag? tag def with_depth_visible_tag? tag, material_filter
tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated] return false unless tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
case material_filter
when 'present'
tag[:has_material]
when 'missing'
!tag[:has_material]
else
true
end
end end
def build_tag_children tag def visible_subtree? tag_id, graph
cache = graph[:visible_subtree_by_tag_id]
return cache[tag_id] if cache.key?(tag_id)
tag = graph[:tags_by_id][tag_id]
return cache[tag_id] = false unless tag
if tag[:deprecated]
return cache[tag_id] =
graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
visible_subtree?(child_tag_id, graph)
}
end
cache[tag_id] =
with_depth_visible_tag?(tag, graph[:material_filter]) ||
graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
visible_subtree?(child_tag_id, graph)
}
end
def with_depth_lightweight_repr tag_id, graph
tag = graph[:tags_by_id][tag_id]
{ id: tag_id,
name: tag[:name],
category: tag[:category],
deprecated: tag[:deprecated],
has_material: tag[:has_material],
has_children: visible_child_tag_ids(tag_id, graph).present?,
children: [] }
end
def build_tag_children tag, graph: nil
material = tag.materials.first material = tag.materials.first
file = nil tag_graph = graph && graph[:tags_by_id][tag.id]
content_type = nil material =
if material&.file&.attached? nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
file = rails_storage_proxy_url(material.file, only_path: false)
content_type = material.file.blob.content_type children = tag.children.sort_by(&:name)
if graph
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
end end
TagRepr.base(tag).merge( TagRepr.base(tag).merge(
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, children: children.map { build_tag_children(_1, graph:) },
material: material.as_json&.merge(file:, content_type:)) has_material: tag_graph ? tag_graph[:has_material] : material.present?,
material: material && MaterialRepr.base(material, host: request.base_url))
end end
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
+24
ファイルの表示
@@ -9,8 +9,13 @@ class Material < ApplicationRecord
belongs_to :tag, optional: true belongs_to :tag, optional: true
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true
belongs_to :file_suppressed_by_user, class_name: 'User', optional: true
has_many :material_versions, dependent: :destroy
has_many :material_export_items, dependent: :destroy
has_one_attached :file, dependent: :purge has_one_attached :file, dependent: :purge
has_one_attached :thumbnail, dependent: :purge
validates :tag_id, presence: true, uniqueness: true validates :tag_id, presence: true, uniqueness: true
@@ -18,11 +23,30 @@ class Material < ApplicationRecord
validate :tag_must_be_material_category validate :tag_must_be_material_category
def content_type def content_type
return nil if file_suppressed?
return nil unless file&.attached? return nil unless file&.attached?
file.blob.content_type file.blob.content_type
end end
def file_byte_size
return nil unless file&.attached?
file.blob.byte_size
end
def file_filename
return nil unless file&.attached?
file.blob.filename.to_s
end
def file_suppressed? = file_suppressed_at.present?
def snapshot_export_paths
material_export_items.order(:profile).pluck(:profile, :export_path).to_h
end
private private
def file_must_be_attached def file_must_be_attached
+48
ファイルの表示
@@ -0,0 +1,48 @@
class MaterialExportItem < ApplicationRecord
VALID_PROFILES = ['legacy_drive'].freeze
belongs_to :material
belongs_to :created_by_user, class_name: 'User', optional: true
validates :profile, presence: true, inclusion: { in: VALID_PROFILES }
validates :export_path, presence: true, uniqueness: { scope: :profile }
validates :material_id, uniqueness: { scope: :profile }
validate :export_path_must_be_relative_safe_path
scope :enabled, -> { where(enabled: true) }
private
def export_path_must_be_relative_safe_path
return if export_path.blank?
if export_path.start_with?('/')
errors.add(:export_path, '絶対パスは使へません.')
end
if export_path.match?(/\A[A-Za-z]:\//)
errors.add(:export_path, '絶対パスは使へません.')
end
if export_path.include?('\\')
errors.add(:export_path, '/ 区切りで指定してください.')
end
if export_path.include?("\0")
errors.add(:export_path, 'NUL は使へません.')
end
parts = export_path.split('/')
if export_path.include?('//')
errors.add(:export_path, '空の path segment は使へません.')
end
if parts.any? { |part| part.in?(['.', '..']) }
errors.add(:export_path, '.. は使へません.')
end
if export_path.end_with?('/')
errors.add(:export_path, 'directory path は使へません.')
end
end
end
+29
ファイルの表示
@@ -0,0 +1,29 @@
class MaterialImportBlock < ApplicationRecord
MATCH_KINDS = ['sha256', 'exact_path', 'path_prefix', 'manual'].freeze
REASONS = [
'copyright_high_risk',
'copyright_takedown',
'adult_or_sensitive',
'personal_information',
'malware_or_dangerous_file',
'duplicate_or_low_quality',
'source_owner_request',
'other'
].freeze
belongs_to :created_by_user, class_name: 'User', optional: true
validates :match_kind, presence: true, inclusion: { in: MATCH_KINDS }
validates :reason, presence: true, inclusion: { in: REASONS }
validates :sha256, length: { is: 64 }, allow_blank: true
validate :match_value_must_be_present
private
def match_value_must_be_present
return if match_kind == 'manual'
return if sha256.present? || external_path_pattern.present?
errors.add(:base, 'sha256 または external_path_pattern は必須です.')
end
end
+18
ファイルの表示
@@ -0,0 +1,18 @@
class MaterialVersion < ApplicationRecord
EVENT_TYPE_MAP = { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore',
suppress: 'suppress' }.freeze
include VersionRecord
belongs_to :material
belongs_to :tag, optional: true
belongs_to :parent, class_name: 'Material', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
def export_paths_hash
export_paths_json || {}
end
end
+12 -4
ファイルの表示
@@ -1,15 +1,23 @@
module VersionRecord module VersionRecord
extend ActiveSupport::Concern extend ActiveSupport::Concern
DEFAULT_EVENT_TYPE_MAP = { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }.freeze
def readonly? = persisted? def readonly? = persisted?
included do included do
event_type_map = if const_defined?(:EVENT_TYPE_MAP, false)
const_get(:EVENT_TYPE_MAP)
else
DEFAULT_EVENT_TYPE_MAP
end
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
enum :event_type, { create: 'create', enum :event_type, event_type_map, prefix: true, validate: true
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true validates :event_type, presence: true
+85 -3
ファイルの表示
@@ -2,7 +2,8 @@
module MaterialRepr module MaterialRepr
BASE = { only: [:id, :url, :created_at, :updated_at], BASE = { only: [:id, :url, :version_no, :file_suppressed_at,
:file_suppression_reason, :created_at, :updated_at],
methods: [:content_type], methods: [:content_type],
include: { tag: TagRepr::BASE, include: { tag: TagRepr::BASE,
created_by_user: UserRepr::BASE, created_by_user: UserRepr::BASE,
@@ -12,13 +13,94 @@ module MaterialRepr
def base material, host: def base material, host:
material.as_json(BASE).merge( material.as_json(BASE).merge(
file: if material.file.attached? file: if material.file.attached? && !material.file_suppressed?
Rails.application.routes.url_helpers.rails_storage_proxy_url( Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:) material.file, host:)
end) end,
thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material),
file_byte_size: material.file_byte_size,
export_paths: export_paths(material),
export_items: export_items(material))
end end
def many materials, host: def many materials, host:
materials.map { |m| base(m, host:) } materials.map { |m| base(m, host:) }
end end
def list material, host:
{ id: material.id,
version_no: material.version_no,
url: material.url,
tag: compact_tag(material.tag),
thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material),
content_type: material.content_type,
file_byte_size: material.file_byte_size,
file_suppressed_at: material.file_suppressed_at,
created_at: material.created_at,
updated_at: material.updated_at,
export_paths: export_paths(material),
export_items: export_items(material) }
end
def list_many materials, host:
materials.map { |m| list(m, host:) }
end
def export_paths material
material.material_export_items.each_with_object({ }) do |item, hash|
hash[item.profile] = item.enabled ? item.export_path : ''
end
end
def export_items material
material.material_export_items.map do |item|
{ id: item.id,
profile: item.profile,
export_path: item.export_path,
enabled: item.enabled }
end
end
def thumbnail_url material, host:
return nil if material.file_suppressed?
return nil unless material.thumbnail.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.thumbnail, host:)
end
def thumbnail_fallback_text material
material.tag&.name || material.created_at&.strftime('%Y-%m-%d')
end
def thumbnail_fallback_kind material
material.tag.present? ? 'tag_name' : 'created_at'
end
def media_kind material
return 'suppressed' if material.file_suppressed?
return 'url_only' unless material.file.attached?
content_type = material.file.blob.content_type.to_s
return 'image' if content_type.start_with?('image/')
return 'video' if content_type.start_with?('video/')
return 'audio' if content_type.start_with?('audio/')
'file_other'
end
def compact_tag tag
return nil unless tag
{ id: tag.id,
name: tag.name,
category: tag.category,
deprecated_at: tag.deprecated_at }
end
end end
+34
ファイルの表示
@@ -0,0 +1,34 @@
require 'digest'
class MaterialFileSha256
def self.from_blob blob, allow_download: false
sha256 = blob.metadata['sha256']
return sha256 if sha256.present?
return nil unless allow_download
begin
blob.open do |file|
sha256 = Digest::SHA256.file(file.path).hexdigest
blob.metadata['sha256'] = sha256
blob.save! if blob.changed?
sha256
end
rescue ActiveStorage::FileNotFoundError, ArgumentError => error
Rails.logger.warn(
"MaterialFileSha256.from_blob failed for blob_id=#{blob.id}: " \
"#{error.class}: #{error.message}",
)
nil
end
end
def self.from_upload upload
tempfile = upload&.tempfile
return nil unless tempfile
tempfile.rewind
Digest::SHA256.file(tempfile.path).hexdigest.tap do
tempfile.rewind
end
end
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class MaterialImportBlockMatcher
def self.match_for_sha256 sha256
return nil if sha256.blank?
MaterialImportBlock.find_by(match_kind: 'sha256', sha256:)
end
end
+86
ファイルの表示
@@ -0,0 +1,86 @@
# frozen_string_literal: true
require 'mini_magick'
require 'open3'
require 'tempfile'
class MaterialThumbnailGenerator
SIZE = '180x180'
class << self
def generate! material
new(material).generate!
rescue ActiveStorage::FileNotFoundError, ArgumentError, MiniMagick::Error => e
Rails.logger.warn("Material thumbnail generation skipped: #{ e.class }: #{ e.message }")
nil
end
end
def initialize material
@material = material
end
def generate!
@material.thumbnail.purge if @material.thumbnail.attached?
return unless @material.file.attached?
return unless image? || video?
@material.file.blob.open do |file|
thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path)
attach_thumbnail(thumbnail) if thumbnail
end
end
private
def image? = content_type.start_with?('image/')
def video? = content_type.start_with?('video/')
def content_type = @material.file.blob.content_type.to_s
def image_thumbnail path
image = MiniMagick::Image.open(path)
image.resize(SIZE)
image.format('jpg')
image
end
def video_thumbnail path
[1, 0].each do |seconds|
tempfile = Tempfile.new(['material-thumbnail', '.jpg'])
tempfile.close
ok = extract_video_frame(path, tempfile.path, seconds)
next unless ok && File.size?(tempfile.path)
return image_thumbnail(tempfile.path)
ensure
tempfile&.unlink
end
nil
end
def extract_video_frame input_path, output_path, seconds
_stdout, stderr, status =
Open3.capture3('ffmpeg',
'-y',
'-ss', seconds.to_s,
'-i', input_path,
'-frames:v', '1',
'-f', 'image2',
output_path)
Rails.logger.warn("ffmpeg thumbnail failed: #{ stderr }") unless status.success?
status.success?
rescue Errno::ENOENT => e
Rails.logger.warn("ffmpeg unavailable for material thumbnail: #{ e.message }")
false
end
def attach_thumbnail image
@material.thumbnail.attach(io: File.open(image.path),
filename: 'material-thumbnail.jpg',
content_type: 'image/jpeg')
end
end
+70
ファイルの表示
@@ -0,0 +1,70 @@
class MaterialVersionRecorder < VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore', 'suppress'].freeze
def self.record! material:, event_type:, created_by_user:, file_snapshot: nil
new(material:, event_type:, created_by_user:, file_snapshot:).record!
end
def initialize material:, event_type:, created_by_user:, file_snapshot: nil
@file_snapshot = file_snapshot
super(record: material, event_type:, created_by_user:)
end
def self.ensure_snapshot! material, created_by_user:
return if material.material_versions.exists?
record!(material:, event_type: :create,
created_by_user: material.created_by_user || created_by_user)
end
private
def version_class = MaterialVersion
def version_association = :material_versions
def record_key = :material
def snapshot_attributes
blob = @record.file.attached? ? @record.file.blob : nil
file_snapshot = build_file_snapshot(blob)
{ url: @record.url,
parent: @record.parent,
tag: @record.tag,
tag_name: @record.tag&.name,
tag_category: @record.tag&.category,
export_paths_json: @record.snapshot_export_paths,
discarded_at: @record.discarded_at,
file_blob_id: file_snapshot[:file_blob_id],
file_filename: file_snapshot[:file_filename],
file_content_type: file_snapshot[:file_content_type],
file_byte_size: file_snapshot[:file_byte_size],
file_checksum: file_snapshot[:file_checksum],
file_sha256: file_snapshot[:file_sha256],
file_suppressed_at: @record.file_suppressed_at,
file_suppression_reason: @record.file_suppression_reason }
end
def build_file_snapshot blob
return @file_snapshot if @file_snapshot
return empty_file_snapshot unless blob
{ file_blob_id: blob.id,
file_filename: blob.filename.to_s,
file_content_type: blob.content_type,
file_byte_size: blob.byte_size,
file_checksum: blob.checksum,
file_sha256: blob.metadata['sha256'] }
end
def empty_file_snapshot
{ file_blob_id: nil,
file_filename: nil,
file_content_type: nil,
file_byte_size: nil,
file_checksum: nil,
file_sha256: nil }
end
def event_types = self.class::EVENT_TYPES
end
+149
ファイルの表示
@@ -0,0 +1,149 @@
require 'stringio'
require 'zlib'
# Initial implementation keeps every file payload and the final ZIP in memory.
# Keep this service boundary stable so job/cached export paths can replace it later.
class MaterialZipExporter
Entry = Struct.new(:path, :data, :mtime, keyword_init: true)
MissingFile = Struct.new(:material_id, :export_path, :blob_id, :filename, keyword_init: true)
class EmptyExportError < StandardError; end
class DuplicatePathError < StandardError; end
class MissingFileError < StandardError
attr_reader :missing_files
def initialize missing_files
@missing_files = missing_files
super("Missing files: #{missing_files.map(&:export_path).join(', ')}")
end
end
def initialize profile: 'legacy_drive', tag_id: nil
@profile = profile.presence || 'legacy_drive'
@tag_id = tag_id.presence
end
def export
entries = build_entries
raise EmptyExportError if entries.empty?
ZipWriter.write(entries)
end
private
def build_entries
rows = MaterialExportItem
.enabled
.includes(material: { file_attachment: :blob })
.joins(:material)
.merge(Material.kept)
.where(profile: @profile)
.where(materials: { file_suppressed_at: nil })
.order(:export_path)
rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id
missing_files = []
entries = rows.filter_map do |item|
material = item.material
next unless material.file.attached?
data = download_blob(item, missing_files)
next unless data
Entry.new(path: item.export_path,
data:,
mtime: material.updated_at || Time.current)
end
raise MissingFileError.new(missing_files) if missing_files.any?
paths = entries.map(&:path)
duplicated = paths.find { |path| paths.count(path) > 1 }
raise DuplicatePathError, duplicated if duplicated
entries
end
def download_blob item, missing_files
blob = item.material.file.blob
blob.download
rescue ActiveStorage::FileNotFoundError
missing_files << MissingFile.new(
material_id: item.material_id,
export_path: item.export_path,
blob_id: blob.id,
filename: blob.filename.to_s,
)
nil
end
class ZipWriter
VERSION_NEEDED = 20
GP_FLAG = 0x0800
COMPRESSION_STORE = 0
def self.write entries
new(entries).write
end
def initialize entries
@entries = entries
@central_directory = []
end
def write
io = StringIO.new(''.b)
@entries.each do |entry|
write_entry(io, entry)
end
central_start = io.pos
@central_directory.each { |header| io.write(header) }
central_size = io.pos - central_start
io.write([0x06054b50, 0, 0, @entries.size, @entries.size,
central_size, central_start, 0].pack('VvvvvVVv'))
io.string
end
private
def write_entry io, entry
path = entry.path.b
data = entry.data.b
crc32 = Zlib.crc32(data)
dos_time, dos_date = dos_timestamp(entry.mtime)
offset = io.pos
local_header = [0x04034b50, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
dos_time, dos_date, crc32, data.bytesize, data.bytesize,
path.bytesize, 0].pack('VvvvvvVVVvv')
io.write(local_header)
io.write(path)
io.write(data)
@central_directory << central_header(path:, crc32:, size: data.bytesize,
dos_time:, dos_date:, offset:)
end
def central_header path:, crc32:, size:, dos_time:, dos_date:, offset:
[0x02014b50, VERSION_NEEDED, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
dos_time, dos_date, crc32, size, size, path.bytesize, 0, 0, 0, 0, 0,
offset].pack('VvvvvvvVVVvvvvvVV') + path
end
def dos_timestamp time
local = time.to_time
dos_time = (local.hour << 11) | (local.min << 5) | (local.sec / 2)
dos_date = ((local.year - 1980) << 9) | (local.month << 5) | local.day
[dos_time, dos_date]
end
end
end
+2 -1
ファイルの表示
@@ -73,7 +73,7 @@ class VersionRecorder
end end
def validate_event_type! def validate_event_type!
return if EVENT_TYPES.include?(@event_type) return if event_types.include?(@event_type)
raise ArgumentError, "Invalid event_type: #{ @event_type }" raise ArgumentError, "Invalid event_type: #{ @event_type }"
end end
@@ -84,4 +84,5 @@ class VersionRecorder
def snapshot_attributes = raise NotImplementedError def snapshot_attributes = raise NotImplementedError
def record_class = @record.class def record_class = @record.class
def event_types = self.class::EVENT_TYPES
end end
+6 -1
ファイルの表示
@@ -113,5 +113,10 @@ Rails.application.routes.draw do
resources :skip_events, controller: :theatre_skip_events, only: [:index] resources :skip_events, controller: :theatre_skip_events, only: [:index]
end end
resources :materials, only: [:index, :show, :create, :update, :destroy] get 'materials/download.zip', to: 'materials#download'
resources :materials, only: [:index, :show, :create, :update, :destroy] do
member do
patch :suppress_file
end
end
end end
+98
ファイルの表示
@@ -0,0 +1,98 @@
class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
def up
change_table :materials, bulk: true do |t|
t.integer :version_no, null: false, default: 1
t.datetime :file_suppressed_at
t.references :file_suppressed_by_user, foreign_key: { to_table: :users }
t.string :file_suppression_reason
end
change_table :material_versions, bulk: true do |t|
t.string :event_type
t.string :tag_name
t.string :tag_category
t.json :export_paths_json
t.bigint :file_blob_id
t.string :file_filename
t.string :file_content_type
t.bigint :file_byte_size
t.string :file_checksum
t.string :file_sha256
t.datetime :file_suppressed_at
t.string :file_suppression_reason
end
execute <<~SQL.squish
UPDATE material_versions
SET event_type = CASE
WHEN version_no = 1 THEN 'create'
ELSE 'update'
END
WHERE event_type IS NULL
SQL
change_column_null :material_versions, :event_type, false
add_index :material_versions, :file_blob_id
add_check_constraint :material_versions,
"event_type IN ('create', 'update', 'discard', 'restore', 'suppress')",
name: 'material_versions_event_type_valid'
create_table :material_export_items do |t|
t.references :material, null: false, foreign_key: true
t.string :profile, null: false, default: 'legacy_drive'
t.string :export_path, null: false
t.boolean :enabled, null: false, default: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.timestamps
t.index [:profile, :export_path], unique: true
t.index [:material_id, :profile], unique: true
end
create_table :material_import_blocks do |t|
t.string :match_kind, null: false
t.string :sha256
t.string :external_path_pattern
t.string :reason, null: false
t.text :note
t.references :created_by_user, foreign_key: { to_table: :users }
t.timestamps
end
execute <<~SQL.squish
UPDATE materials
SET version_no = COALESCE(
(SELECT MAX(material_versions.version_no)
FROM material_versions
WHERE material_versions.material_id = materials.id),
1)
SQL
end
def down
drop_table :material_import_blocks
drop_table :material_export_items
remove_check_constraint :material_versions, name: 'material_versions_event_type_valid'
remove_index :material_versions, :file_blob_id
remove_column :material_versions, :event_type
remove_column :material_versions, :tag_name
remove_column :material_versions, :tag_category
remove_column :material_versions, :export_paths_json
remove_column :material_versions, :file_blob_id
remove_column :material_versions, :file_filename
remove_column :material_versions, :file_content_type
remove_column :material_versions, :file_byte_size
remove_column :material_versions, :file_checksum
remove_column :material_versions, :file_sha256
remove_column :material_versions, :file_suppressed_at
remove_column :material_versions, :file_suppression_reason
remove_reference :materials, :file_suppressed_by_user, foreign_key: { to_table: :users }
remove_column :materials, :version_no
remove_column :materials, :file_suppressed_at
remove_column :materials, :file_suppression_reason
end
end
生成ファイル
+50 -1
ファイルの表示
@@ -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_21_000000) do ActiveRecord::Schema[8.0].define(version: 2026_06_23_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
@@ -130,6 +130,32 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end end
create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false
t.string "profile", default: "legacy_drive", null: false
t.string "export_path", null: false
t.boolean "enabled", default: true, null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_material_export_items_on_created_by_user_id"
t.index ["material_id", "profile"], name: "index_material_export_items_on_material_id_and_profile", unique: true
t.index ["material_id"], name: "index_material_export_items_on_material_id"
t.index ["profile", "export_path"], name: "index_material_export_items_on_profile_and_export_path", unique: true
end
create_table "material_import_blocks", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "match_kind", null: false
t.string "sha256"
t.string "external_path_pattern"
t.string "reason", null: false
t.text "note"
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_material_import_blocks_on_created_by_user_id"
end
create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false t.bigint "material_id", null: false
t.integer "version_no", null: false t.integer "version_no", null: false
@@ -141,14 +167,28 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
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.datetime "discarded_at" t.datetime "discarded_at"
t.string "event_type", null: false
t.string "tag_name"
t.string "tag_category"
t.json "export_paths_json"
t.bigint "file_blob_id"
t.string "file_filename"
t.string "file_content_type"
t.bigint "file_byte_size"
t.string "file_checksum"
t.string "file_sha256"
t.datetime "file_suppressed_at"
t.string "file_suppression_reason"
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at" t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id"
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
t.index ["material_id"], name: "index_material_versions_on_material_id" t.index ["material_id"], name: "index_material_versions_on_material_id"
t.index ["parent_id"], name: "index_material_versions_on_parent_id" t.index ["parent_id"], name: "index_material_versions_on_parent_id"
t.index ["tag_id"], name: "index_material_versions_on_tag_id" t.index ["tag_id"], name: "index_material_versions_on_tag_id"
t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id" t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
t.index ["url"], name: "index_material_versions_on_url" t.index ["url"], name: "index_material_versions_on_url"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore',_utf8mb4'suppress')", name: "material_versions_event_type_valid"
end end
create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -161,9 +201,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)" t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
t.integer "version_no", default: 1, null: false
t.datetime "file_suppressed_at"
t.bigint "file_suppressed_by_user_id"
t.string "file_suppression_reason"
t.index ["active_url"], name: "index_materials_on_active_url", unique: true t.index ["active_url"], name: "index_materials_on_active_url", unique: true
t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
t.index ["discarded_at"], name: "index_materials_on_discarded_at" t.index ["discarded_at"], name: "index_materials_on_discarded_at"
t.index ["file_suppressed_by_user_id"], name: "index_materials_on_file_suppressed_by_user_id"
t.index ["parent_id"], name: "index_materials_on_parent_id" t.index ["parent_id"], name: "index_materials_on_parent_id"
t.index ["tag_id"], name: "index_materials_on_tag_id" t.index ["tag_id"], name: "index_materials_on_tag_id"
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id" t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
@@ -567,6 +612,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions" add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
add_foreign_key "gekanator_questions", "users", column: "created_by_id" add_foreign_key "gekanator_questions", "users", column: "created_by_id"
add_foreign_key "material_export_items", "materials"
add_foreign_key "material_export_items", "users", column: "created_by_user_id"
add_foreign_key "material_import_blocks", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags" add_foreign_key "material_versions", "tags"
@@ -575,6 +623,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
add_foreign_key "materials", "materials", column: "parent_id" add_foreign_key "materials", "materials", column: "parent_id"
add_foreign_key "materials", "tags" add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id" add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "file_suppressed_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id" add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
+57
ファイルの表示
@@ -0,0 +1,57 @@
require 'rails_helper'
RSpec.describe MaterialExportItem, type: :model do
let(:user) { create(:user, :member) }
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) }
let(:material) do
Material.create!(tag:, url: 'https://example.com/material',
created_by_user: user, updated_by_user: user)
end
it 'rejects blank export_path' do
item = described_class.new(material:, profile: 'legacy_drive', export_path: '')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects absolute export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '/素材/a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects parent traversal export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '素材/../a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects double slash export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '素材//a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects dot segment export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: './素材/a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects trailing slash export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '素材/a/')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
end
+282 -25
ファイルの表示
@@ -1,7 +1,10 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Materials API', type: :request do RSpec.describe 'Materials API', type: :request do
include ActiveJob::TestHelper
let!(:member_user) { create(:user, :member) } let!(:member_user) { create(:user, :member) }
let!(:admin_user) { create(:user, :admin) }
let!(:guest_user) { create(:user) } let!(:guest_user) { create(:user) }
def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy') def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
@@ -13,22 +16,29 @@ RSpec.describe 'Materials API', type: :request do
end end
def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil) def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil)
Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material| Material.new(tag:, parent:, url:,
created_by_user: user,
updated_by_user: user).tap do |material|
material.file.attach(file) if file material.file.attach(file) if file
material.save! material.save!
end end
end end
describe 'GET /materials' do describe 'GET /materials' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) } let!(:tag_a) do
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) } Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material)
end
let!(:tag_b) do
Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material)
end
let!(:material_a) do let!(:material_a) do
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png')) build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
end end
let!(:material_b) do let!(:material_b) do
build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png')) build_material(tag: tag_b, user: member_user, parent: material_a,
file: dummy_upload(filename: 'b.png'))
end end
before do before do
@@ -97,7 +107,9 @@ RSpec.describe 'Materials API', type: :request do
end end
describe 'GET /materials/:id' do describe 'GET /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) } let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material)
end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
end end
@@ -138,9 +150,22 @@ RSpec.describe 'Materials API', type: :request do
end end
end end
context 'when logged in' do context 'when logged in but not member' do
before { sign_in_as(guest_user) } before { sign_in_as(guest_user) }
it 'returns 403' do
post '/materials', params: {
tag: 'material_create_guest_forbidden',
file: dummy_upload
}
expect(response).to have_http_status(:forbidden)
end
end
context 'when member' do
before { sign_in_as(member_user) }
it 'returns 422 when tag is blank' do it 'returns 422 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload } post '/materials', params: { tag: ' ', file: dummy_upload }
@@ -162,24 +187,49 @@ RSpec.describe 'Materials API', type: :request do
expect do expect do
post '/materials', params: { post '/materials', params: {
tag: 'material_create_new', tag: 'material_create_new',
file: dummy_upload(filename: 'created.png') file: dummy_upload(filename: 'created.png'),
export_paths: { legacy_drive: '伊地知ニジカ/created.png' }
} }
end.to change(Material, :count).by(1) end.to change(Material, :count).by(1)
.and change(Tag, :count).by(1) .and change(Tag, :count).by(1)
.and change(TagName, :count).by(1) .and change(TagName, :count).by(1)
.and change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
material = Material.order(:id).last material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_new') expect(material.tag.name).to eq('material_create_new')
expect(material.tag.category).to eq('material') expect(material.tag.category).to eq('material')
expect(material.created_by_user).to eq(guest_user) expect(material.created_by_user).to eq(member_user)
expect(material.updated_by_user).to eq(guest_user) expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(true) expect(material.file.attached?).to be(true)
expect(material.version_no).to eq(1)
expect(material.material_versions.first.event_type).to eq('create')
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png')
expect(material.material_versions.first.export_paths_json).to eq(
'legacy_drive' => '伊地知ニジカ/created.png'
)
expect(json['id']).to eq(material.id) expect(json['id']).to eq(material.id)
expect(json.dig('tag', 'name')).to eq('material_create_new') expect(json.dig('tag', 'name')).to eq('material_create_new')
expect(json['content_type']).to eq('image/png') expect(json['content_type']).to eq('image/png')
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
end
it 'snapshots attached file metadata and sha256' do
post '/materials', params: {
tag: 'material_create_file_version',
file: dummy_upload(filename: 'created.png', body: 'sha-body')
}
expect(response).to have_http_status(:created)
version = Material.order(:id).last.material_versions.first
expect(version.file_blob_id).to be_present
expect(version.file_filename).to eq('created.png')
expect(version.file_content_type).to eq('image/png')
expect(version.file_byte_size).to eq('sha-body'.bytesize)
expect(version.file_sha256).to eq(Digest::SHA256.hexdigest('sha-body'))
end end
it 'returns 422 when the existing tag is not material/character' do it 'returns 422 when the existing tag is not material/character' do
@@ -219,11 +269,33 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(json['url']).to eq('https://example.com/material-source') expect(json['url']).to eq('https://example.com/material-source')
end end
it 'rejects sha256-blocked file upload' do
sha256 = Digest::SHA256.hexdigest('blocked-body')
MaterialImportBlock.create!(match_kind: 'sha256',
sha256:,
reason: 'copyright_high_risk',
created_by_user: admin_user)
expect do
post '/materials', params: {
tag: 'material_blocked_create',
file: dummy_upload(filename: 'blocked.png', body: 'blocked-body')
}
end.not_to change(Material, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['抑止された素材です: copyright_high_risk']
)
end
end end
end end
describe 'PUT /materials/:id' do describe 'PUT /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) } let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material)
end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
end end
@@ -277,25 +349,26 @@ RSpec.describe 'Materials API', type: :request do
'tag' => ['タグは必須です.']) 'tag' => ['タグは必須です.'])
end end
it 'returns 422 when both file and url are blank' do it 'keeps the existing file when file and url are omitted' do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload' tag: 'material_update_no_payload'
} }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:ok)
expect(json.fetch('errors')).to include( expect(material.reload.file.attached?).to be(true)
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
end end
it 'updates tag, url, file, and updated_by_user' do it 'updates tag, url, file, and updated_by_user' do
old_blob_id = material.file.blob.id old_blob_id = material.file.blob.id
put "/materials/#{ material.id }", params: { expect do
tag: 'material_update_new', put "/materials/#{ material.id }", params: {
url: 'https://example.com/updated-source', tag: 'material_update_new',
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg') url: 'https://example.com/updated-source',
} file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg'),
export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' }
}
end.to change(MaterialVersion, :count).by(2)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -306,8 +379,15 @@ RSpec.describe 'Materials API', type: :request do
expect(material.updated_by_user).to eq(member_user) expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(true) expect(material.file.attached?).to be(true)
expect(material.file.blob.id).not_to eq(old_blob_id) expect(material.file.blob.id).not_to eq(old_blob_id)
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
expect(material.file.blob.filename.to_s).to eq('updated.jpg') expect(material.file.blob.filename.to_s).to eq('updated.jpg')
expect(material.file.blob.content_type).to eq('image/jpeg') expect(material.file.blob.content_type).to eq('image/jpeg')
expect(material.version_no).to eq(2)
expect(material.material_versions.order(:version_no).last.event_type).to eq('update')
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/updated.jpg')
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq(
'legacy_drive' => '伊地知ニジカ/updated.jpg'
)
expect(json['id']).to eq(material.id) expect(json['id']).to eq(material.id)
expect(json['file']).to be_present expect(json['file']).to be_present
@@ -315,7 +395,7 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('tag', 'name')).to eq('material_update_new') expect(json.dig('tag', 'name')).to eq('material_update_new')
end end
it 'purges the existing file when file is omitted and url is provided' do it 'detaches the existing file without purging blob when url replaces file' do
old_blob_id = material.file.blob.id old_blob_id = material.file.blob.id
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
@@ -331,9 +411,7 @@ RSpec.describe 'Materials API', type: :request do
expect(material.updated_by_user).to eq(member_user) expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(false) expect(material.file.attached?).to be(false)
expect( expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
ActiveStorage::Blob.where(id: old_blob_id).exists?
).to be(false)
expect(json['id']).to eq(material.id) expect(json['id']).to eq(material.id)
expect(json['file']).to be_nil expect(json['file']).to be_nil
@@ -341,11 +419,190 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('tag', 'name')).to eq('material_update_remove_file') expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
expect(json['url']).to eq('https://example.com/updated-source') expect(json['url']).to eq('https://example.com/updated-source')
end end
it 'does not increase version for the same snapshot update' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_old'
}
end.not_to change(MaterialVersion, :count)
expect(response).to have_http_status(:ok)
expect(material.reload.version_no).to eq(1)
end
it 'records update version when only export_path changes' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_old',
export_paths: { legacy_drive: '素材/only-path.png' }
}
end.to change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(material.reload.material_export_items.first.export_path).to eq('素材/only-path.png')
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq(
'legacy_drive' => '素材/only-path.png'
)
end
it 'removes export_path item when blank is submitted' do
MaterialExportItem.create!(material:, profile: 'legacy_drive',
export_path: '素材/remove.png',
created_by_user: member_user)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_old',
export_paths: { legacy_drive: '' }
}
end.to change(MaterialExportItem, :count).by(-1)
.and change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(material.reload.material_export_items).to be_empty
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq({})
end
it 'rejects sha256-blocked replacement file' do
sha256 = Digest::SHA256.hexdigest('blocked-update')
MaterialImportBlock.create!(match_kind: 'sha256',
sha256:,
reason: 'source_owner_request',
created_by_user: admin_user)
put "/materials/#{ material.id }", params: {
tag: 'material_update_old',
file: dummy_upload(filename: 'blocked.png', body: 'blocked-update')
}
expect(response).to have_http_status(:unprocessable_entity)
expect(material.reload.file.blob.filename.to_s).to eq('old.png')
end
end
end
describe 'GET /materials/download.zip' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) }
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) }
let!(:material_a) do
build_material(tag: tag_a, user: member_user,
file: dummy_upload(filename: 'a.png', body: 'zip-a'))
end
let!(:material_b) do
build_material(tag: tag_b, user: member_user,
file: dummy_upload(filename: 'b.png', body: 'zip-b'))
end
before do
MaterialExportItem.create!(material: material_a, profile: 'legacy_drive',
export_path: '素材/a.png',
created_by_user: member_user)
MaterialExportItem.create!(material: material_b, profile: 'legacy_drive',
export_path: '素材/b.png',
created_by_user: member_user)
end
it 'uses material_export_items.export_path as ZIP entry paths' do
get '/materials/download.zip', params: { profile: 'legacy_drive' }
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('application/zip')
expect(response.body.b).to include('素材/a.png'.b)
expect(response.body.b).to include('素材/b.png'.b)
end
it 'filters by tag_id' do
get '/materials/download.zip', params: { profile: 'legacy_drive', tag_id: tag_a.id }
expect(response).to have_http_status(:ok)
expect(response.body.b).to include('素材/a.png'.b)
expect(response.body.b).not_to include('素材/b.png'.b)
end
it 'does not include suppressed materials' do
material_b.update!(file_suppressed_at: Time.current,
file_suppression_reason: 'copyright_high_risk')
get '/materials/download.zip', params: { profile: 'legacy_drive' }
expect(response).to have_http_status(:ok)
expect(response.body.b).to include('素材/a.png'.b)
expect(response.body.b).not_to include('素材/b.png'.b)
end
end
describe 'PATCH /materials/:id/suppress_file' do
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_suppress'), category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'suppress.png'))
end
it 'allows admin to suppress a file and records a suppress version' do
sign_in_as(admin_user)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
patch "/materials/#{ material.id }/suppress_file",
params: { reason: 'copyright_high_risk' }
end.to change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:ok)
material.reload
expect(material.file_suppressed_at).to be_present
expect(material.file_suppressed_by_user).to eq(admin_user)
expect(material.file_suppression_reason).to eq('copyright_high_risk')
expect(material.material_versions.order(:version_no).last.event_type).to eq('suppress')
expect(json['file']).to be_nil
expect(json['file_suppressed_at']).to be_present
end
it 'purges blob when purge=true is requested' do
sign_in_as(admin_user)
old_blob_id = material.file.blob.id
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
patch "/materials/#{ material.id }/suppress_file",
params: { reason: 'copyright_takedown', purge: '1' }
end.to have_enqueued_job(ActiveStorage::PurgeJob)
expect(response).to have_http_status(:ok)
version = material.material_versions.order(:version_no).last
expect(version.event_type).to eq('suppress')
expect(version.file_blob_id).to eq(old_blob_id)
expect(version.file_filename).to eq('suppress.png')
expect(version.file_sha256).to be_present
end
it 'rejects member suppression' do
sign_in_as(member_user)
patch "/materials/#{ material.id }/suppress_file",
params: { reason: 'copyright_high_risk' }
expect(response).to have_http_status(:forbidden)
end end
end end
describe 'DELETE /materials/:id' do describe 'DELETE /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) } let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material)
end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
end end
+1 -1
ファイルの表示
@@ -69,7 +69,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/materials" element={<MaterialBasePage/>}> <Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/> <Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/> <Route path="new" element={<MaterialNewPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/> <Route path=":id" element ={<MaterialDetailPage user={user}/>}/>
</Route> </Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */} {/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/> <Route path="/wiki" element={<WikiSearchPage/>}/>
+289 -74
ファイルの表示
@@ -1,23 +1,28 @@
import { Fragment, useEffect, useState } from 'react' import { Fragment, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useLocation, useNavigate } from 'react-router-dom'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { apiGet } from '@/lib/api' import { materialsKeys } from '@/lib/queryKeys'
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
import type { FC, ReactNode } from 'react' import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { Tag } from '@/types' import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
type TagWithDepth = Tag & { const FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
hasChildren: boolean
children: TagWithDepth[] } const FILTER_LABELS: Record<MaterialFilter, string> = {
present: '素材あり',
missing: '素材なし',
any: 'すべて'}
const setChildrenById = ( const setChildrenById = (
tags: TagWithDepth[], tags: MaterialSidebarTag[],
targetId: number, targetId: number,
children: TagWithDepth[], children: MaterialSidebarTag[]): MaterialSidebarTag[] => (
): TagWithDepth[] => (
tags.map (tag => { tags.map (tag => {
if (tag.id === targetId) if (tag.id === targetId)
return { ...tag, children } return { ...tag, children }
@@ -25,75 +30,285 @@ const setChildrenById = (
if (tag.children.length === 0) if (tag.children.length === 0)
return tag return tag
return { ...tag, return { ...tag, children: setChildrenById (tag.children, targetId, children) }
children: (setChildrenById (tag.children, targetId, children)
.filter (t => t.category !== 'meme' || t.hasChildren)) }
})) }))
const MaterialSidebar: FC = () => { const materialPath = (
const [tags, setTags] = useState<TagWithDepth[]> ([]) tagName: string,
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) materialFilter: MaterialFilter): string => `/materials?q=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }`
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
useEffect (() => {
void (async () => {
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
.filter (t => t.category !== 'meme' || t.hasChildren))
}) ()
}, [])
const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => ( const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
ts.map (t => ( id: tag.id,
<Fragment key={t.id}> name: tag.name,
<li> category: tag.category,
<div className="flex"> deprecatedAt: tag.deprecated ? '' : null,
<div className="flex-none w-4"> aliases: [],
{t.hasChildren && ( parents: [],
<a postCount: 0,
href="#" createdAt: '',
onClick={async e => { updatedAt: '',
e.preventDefault () hasWiki: false,
if (!(tagFetchedFlags[t.id])) materialId: null,
{ hasDeerjikists: false,
try matchedAlias: null })
{
const data =
await apiGet<TagWithDepth[]> (
'/tags/with-depth', { params: { parent: String (t.id) } })
setTags (prev => setChildrenById (prev, t.id, data))
setTagFetchedFlags (prev => ({ ...prev, [t.id]: true }))
}
catch
{
;
}
}
setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) }))
}}>
{openTags[t.id] ? <>&minus;</> : '+'}
</a>)}
</div>
<div className="flex-1 truncate">
<TagLink
tag={t}
nestLevel={nestLevel}
title={t.name}
withCount={false}
withWiki={false}
to={`/materials?tag=${ encodeURIComponent (t.name) }`}/>
</div>
</div>
</li>
{openTags[t.id] && renderTags (t.children, nestLevel + 1)}
</Fragment>)))
return (
<SidebarComponent> const updateMaterialFilterQuery = (
<ul> pathname: string,
{renderTags (tags)} locationSearch: string,
</ul> navigate: ReturnType<typeof useNavigate>,
</SidebarComponent>) materialFilter: MaterialFilter) => {
const qs = new URLSearchParams (locationSearch)
qs.set ('material_filter', materialFilter)
navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`)
} }
export default MaterialSidebar
const MaterialFilterButtons: FC<{
materialFilter: MaterialFilter
onChange: (materialFilter: MaterialFilter) => void
}> = ({ materialFilter, onChange }) => (
<div className="flex flex-wrap gap-2">
{FILTERS.map (value => (
<button
key={value}
type="button"
onClick={() => onChange (value)}
className={`rounded-full border px-3 py-1 text-sm ${
materialFilter === value
? 'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400 ' +
'dark:bg-sky-950 dark:text-sky-100'
: 'border-neutral-300 bg-white text-neutral-700 dark:border-stone-700 ' +
'dark:bg-stone-900 dark:text-stone-200' }`}>
{FILTER_LABELS[value]}
</button>))}
</div>)
const MaterialTreeNode: FC<{
materialFilter: MaterialFilter
nestLevel?: number
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
openTags: Record<number, boolean>
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
tag: MaterialSidebarTag
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, setOpenTags, tag }) => {
const open = Boolean (openTags[tag.id])
const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => {
if (open && data && tag.children.length === 0)
onChildren (tag.id, data)
}, [data, onChildren, open, tag.children.length, tag.id])
return (
<Fragment>
<li>
<div className="flex">
<div className="flex-none w-4">
{tag.hasChildren && (
<button
type="button"
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
className="text-neutral-500 dark:text-stone-400">
{open ? <>&minus;</> : '+'}
</button>)}
</div>
<div className="flex-1 truncate">
<TagLink
tag={sidebarTagToTag (tag)}
nestLevel={nestLevel}
title={tag.name}
withCount={false}
withWiki={false}
to={materialPath (tag.name, materialFilter)}/>
</div>
</div>
</li>
{open && tag.children.length > 0 && (
<ul>
{tag.children.map (child => (
<MaterialTreeNode
key={child.id}
tag={child}
nestLevel={nestLevel + 1}
materialFilter={materialFilter}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={onChildren}/>))}
</ul>)}
</Fragment>)
}
const MobileMaterialTreeNode: FC<{
depth?: number
materialFilter: MaterialFilter
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
openTags: Record<number, boolean>
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
tag: MaterialSidebarTag
}> = ({ depth = 0, materialFilter, onChildren, openTags, setOpenTags, tag }) => {
const open = Boolean (openTags[tag.id])
const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => {
if (open && data && tag.children.length === 0)
onChildren (tag.id, data)
}, [data, onChildren, open, tag.children.length, tag.id])
return (
<div className="flex flex-row-reverse items-start gap-2">
<div className="flex flex-col items-center gap-1">
<div
className="rounded-xl border border-stone-300 bg-white px-3 py-2 text-sm
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100"
style={{ writingMode: 'vertical-rl' }}>
<TagLink
tag={sidebarTagToTag (tag)}
title={tag.name}
withCount={false}
withWiki={false}
to={materialPath (tag.name, materialFilter)}/>
</div>
{tag.hasChildren && (
<button
type="button"
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
className="rounded-full border border-stone-300 bg-white px-2 py-0.5
text-sm text-stone-700 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100">
{open ? <>&minus;</> : '+'}
</button>)}
</div>
{open && tag.children.length > 0 && (
<div
className="relative flex flex-row-reverse items-start gap-2 rounded-2xl border
border-stone-200 bg-stone-100/70 py-2 pl-2 pr-2 text-stone-900
dark:border-stone-700 dark:bg-stone-900/70 dark:text-stone-100"
style={{ marginTop: `${ depth === 0 ? 1.25 : .75 }rem` }}>
<span
aria-hidden="true"
className="absolute -right-3 top-4 h-px w-3 bg-stone-300 dark:bg-stone-600"/>
{tag.children.map (child => (
<div key={child.id} className="relative">
<MobileMaterialTreeNode
tag={child}
depth={depth + 1}
materialFilter={materialFilter}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={onChildren}/>
</div>))}
</div>)}
</div>)
}
const MaterialSidebar: FC = () => {
const location = useLocation ()
const navigate = useNavigate ()
const qs = new URLSearchParams (location.search)
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'present')
const [desktopTags, setDesktopTags] = useState<MaterialSidebarTag[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const mobileRailRef = useRef<HTMLDivElement | null> (null)
const { data: rootTags = [], isLoading, isError } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
useEffect (() => {
setDesktopTags (rootTags)
}, [rootTags])
useEffect (() => {
const el = mobileRailRef.current
if (!(el))
return
requestAnimationFrame (() => {
el.scrollLeft = el.scrollWidth
})
}, [rootTags, materialFilter])
const visibleRootTags = desktopTags.length > 0 ? desktopTags : rootTags
const setChildren = (tagId: number, children: MaterialSidebarTag[]) => {
setDesktopTags (prev => {
const base = prev.length > 0 ? prev : rootTags
return setChildrenById (base, tagId, children)
})
}
const handleFilterChange = (value: MaterialFilter) => {
setDesktopTags ([])
setOpenTags ({ })
updateMaterialFilterQuery (location.pathname, location.search, navigate, value)
}
const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => (
tags.map (tag => (
<MaterialTreeNode
key={tag.id}
tag={tag}
materialFilter={materialFilter}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={setChildren}/>)))
return (
<>
<div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950
dark:text-stone-100 md:hidden">
<MaterialFilterButtons
materialFilter={materialFilter}
onChange={handleFilterChange}/>
<div ref={mobileRailRef} className="mt-3 overflow-x-auto">
<div className="flex min-w-max flex-row-reverse gap-3 pb-1">
{visibleRootTags.map (tag => (
<MobileMaterialTreeNode
key={tag.id}
tag={tag}
materialFilter={materialFilter}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={setChildren}/>))}
</div>
</div>
</div>
<div className="hidden md:block">
<SidebarComponent>
<div className="space-y-4">
<MaterialFilterButtons
materialFilter={materialFilter}
onChange={handleFilterChange}/>
{isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400"></p>)}
{isError && (
<p className="text-sm text-red-600 dark:text-red-300">
</p>)}
{(!isLoading && !isError) && (
<ul>
{renderDesktopTree (visibleRootTags)}
</ul>)}
</div>
</SidebarComponent>
</div>
</>)
}
export default MaterialSidebar
+1 -2
ファイルの表示
@@ -107,8 +107,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
loadCompleteTimerRef.current = setTimeout (() => { loadCompleteTimerRef.current = setTimeout (() => {
onError?.({ onError?.({
eventName: 'loadCompleteTimeout', eventName: 'loadCompleteTimeout',
reason: 'niconico video length was not reported by embed', reason: 'niconico video length was not reported by embed'})
})
}, LOAD_COMPLETE_TIMEOUT_MS) }, LOAD_COMPLETE_TIMEOUT_MS)
}, [clearLoadCompleteTimer, onError]) }, [clearLoadCompleteTimer, onError])
+1 -2
ファイルの表示
@@ -19,8 +19,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom, setOriginalCreatedFrom,
originalCreatedBefore, originalCreatedBefore,
setOriginalCreatedBefore, setOriginalCreatedBefore,
errors }: Props, errors }: Props) => (
) => (
<FormField label="オリジナルの作成日時" messages={errors}> <FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
+5 -10
ファイルの表示
@@ -37,8 +37,7 @@ const renderTagTree = (
path: string, path: string,
suppressClickRef: MutableRefObject<boolean>, suppressClickRef: MutableRefObject<boolean>,
parentTagId?: number, parentTagId?: number,
sp?: boolean, sp?: boolean): ReactNode[] => {
): ReactNode[] => {
const key = `${ path }-${ tag.id }` const key = `${ path }-${ tag.id }`
const self = ( const self = (
@@ -64,8 +63,7 @@ const renderTagTree = (
const isDescendant = ( const isDescendant = (
root: Tag, root: Tag,
targetId: number, targetId: number): boolean => {
): boolean => {
if (!(root.children)) if (!(root.children))
return false return false
@@ -83,8 +81,7 @@ const isDescendant = (
const findTag = ( const findTag = (
byCat: TagByCategory, byCat: TagByCategory,
id: number, id: number): Tag | undefined => {
): Tag | undefined => {
const walk = (nodes: Tag[]): Tag | undefined => { const walk = (nodes: Tag[]): Tag | undefined => {
for (const t of nodes) for (const t of nodes)
{ {
@@ -130,8 +127,7 @@ const buildTagByCategory = (post: Post): TagByCategory => {
const changeCategory = async ( const changeCategory = async (
tagId: number, tagId: number,
category: Category, category: Category): Promise<void> => {
): Promise<void> => {
await apiPatch (`/tags/${ tagId }`, { category }) await apiPatch (`/tags/${ tagId }`, { category })
} }
@@ -294,8 +290,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
addEventListener ('click', e => { addEventListener ('click', e => {
e.preventDefault () e.preventDefault ()
e.stopPropagation () e.stopPropagation ()
suppressClickRef.current = false suppressClickRef.current = false}, { capture: true, once: true })
}, { capture: true, once: true })
}} }}
onDragCancel={() => { onDragCancel={() => {
setActiveTagId (null) setActiveTagId (null)
+1 -2
ファイルの表示
@@ -16,8 +16,7 @@ const range = (start: number, end: number): number[] =>
const getPages = ( const getPages = (
page: number, page: number,
total: number, total: number,
siblingCount: number, siblingCount: number): (number | '…')[] => {
): (number | '…')[] => {
if (total <= 1) if (total <= 1)
return [1] return [1]
+1 -2
ファイルの表示
@@ -103,8 +103,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
choice: options => new Promise (resolve => { choice: options => new Promise (resolve => {
push ({ kind: 'choice', push ({ kind: 'choice',
options: options as ChoiceOptions<string>, options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void }) resolve: resolve as (value: string | null) => void })}) }), [push])
}) }), [push])
const active = queue[0] const active = queue[0]
+23 -31
ファイルの表示
@@ -10,41 +10,35 @@ const buttonVariants = cva (
'rounded-md text-sm font-medium transition-colors', 'rounded-md text-sm font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400',
'disabled:pointer-events-none disabled:opacity-50', 'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'].join (' '),
].join (' '),
{ {
variants: { variants: {
variant: { variant: {
default: default:
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300', 'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
destructive: destructive:
'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600', 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
outline: outline:
'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800', 'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800',
secondary: secondary:
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700', 'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
ghost: ghost:
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800', 'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
link: link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300', 'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300'},
},
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3', sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8', lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10', icon: 'h-10 w-10'}},
},
},
defaultVariants: { defaultVariants: {
variant: 'default', variant: 'default',
size: 'default', size: 'default'}})
},
})
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -57,13 +51,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />)
) })
}
)
Button.displayName = "Button" Button.displayName = "Button"
export { Button, buttonVariants } export { Button, buttonVariants }
+10 -20
ファイルの表示
@@ -22,11 +22,9 @@ const DialogOverlay = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className)}
)}
{...props} {...props}
/> />))
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
@@ -62,8 +60,7 @@ const DialogContent = React.forwardRef<
<span className="sr-only"></span> <span className="sr-only"></span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>))
))
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ const DialogHeader = ({
@@ -73,11 +70,9 @@ const DialogHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1.5 text-center sm:text-left",
className className)}
)}
{...props} {...props}
/> />)
)
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ const DialogFooter = ({
@@ -87,11 +82,9 @@ const DialogFooter = ({
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className)}
)}
{...props} {...props}
/> />)
)
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
@@ -102,11 +95,9 @@ const DialogTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className)}
)}
{...props} {...props}
/> />))
))
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
@@ -117,8 +108,7 @@ const DialogDescription = React.forwardRef<
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />))
))
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName
export { export {
+19 -22
ファイルの表示
@@ -1,22 +1,19 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
return ( return (
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) )
} }
) )
Input.displayName = "Input"
export { Input }
+26 -29
ファイルの表示
@@ -1,29 +1,26 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch" import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const Switch = React.forwardRef< const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>, React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className className
)} )}
{...props} {...props}
ref={ref} ref={ref}
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>
)) ))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+115 -129
ファイルの表示
@@ -1,129 +1,115 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast" import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react" import { X } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className className
)} )}
{...props} {...props}
/> />
)) ))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{ {
variants: { variants: {
variant: { variant: {
default: "border bg-background text-foreground", default: "border bg-background text-foreground",
destructive: destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground", "destructive group border-destructive bg-destructive text-destructive-foreground",
}, },
}, },
defaultVariants: { defaultVariants: { variant: "default" } }
variant: "default", )
},
} const Toast = React.forwardRef<
) React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
const Toast = React.forwardRef< VariantProps<typeof toastVariants>
React.ElementRef<typeof ToastPrimitives.Root>, >(({ className, variant, ...props }, ref) => {
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & return (
VariantProps<typeof toastVariants> <ToastPrimitives.Root
>(({ className, variant, ...props }, ref) => { ref={ref}
return ( className={cn(toastVariants({ variant }), className)}
<ToastPrimitives.Root {...props}
ref={ref} />
className={cn(toastVariants({ variant }), className)} )
{...props} })
/> Toast.displayName = ToastPrimitives.Root.displayName
)
}) const ToastAction = React.forwardRef<
Toast.displayName = ToastPrimitives.Root.displayName React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
const ToastAction = React.forwardRef< >(({ className, ...props }, ref) => (
React.ElementRef<typeof ToastPrimitives.Action>, <ToastPrimitives.Action
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> ref={ref}
>(({ className, ...props }, ref) => ( className={cn(
<ToastPrimitives.Action "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
ref={ref} className
className={cn( )}
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", {...props}
className />
)} ))
{...props} ToastAction.displayName = ToastPrimitives.Action.displayName
/>
)) const ToastClose = React.forwardRef<
ToastAction.displayName = ToastPrimitives.Action.displayName React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
const ToastClose = React.forwardRef< >(({ className, ...props }, ref) => (
React.ElementRef<typeof ToastPrimitives.Close>, <ToastPrimitives.Close
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> ref={ref}
>(({ className, ...props }, ref) => ( className={cn(
<ToastPrimitives.Close "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
ref={ref} className
className={cn( )}
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", toast-close=""
className {...props}
)} >
toast-close="" <X className="h-4 w-4" />
{...props} </ToastPrimitives.Close>
> ))
<X className="h-4 w-4" /> ToastClose.displayName = ToastPrimitives.Close.displayName
</ToastPrimitives.Close>
)) const ToastTitle = React.forwardRef<
ToastClose.displayName = ToastPrimitives.Close.displayName React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
const ToastTitle = React.forwardRef< >(({ className, ...props }, ref) => (
React.ElementRef<typeof ToastPrimitives.Title>, <ToastPrimitives.Title
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> ref={ref}
>(({ className, ...props }, ref) => ( className={cn("text-sm font-semibold", className)}
<ToastPrimitives.Title {...props}
ref={ref} />
className={cn("text-sm font-semibold", className)} ))
{...props} ToastTitle.displayName = ToastPrimitives.Title.displayName
/>
)) const ToastDescription = React.forwardRef<
ToastTitle.displayName = ToastPrimitives.Title.displayName React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
const ToastDescription = React.forwardRef< >(({ className, ...props }, ref) => (
React.ElementRef<typeof ToastPrimitives.Description>, <ToastPrimitives.Description
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> ref={ref}
>(({ className, ...props }, ref) => ( className={cn("text-sm opacity-90", className)}
<ToastPrimitives.Description {...props}
ref={ref} />
className={cn("text-sm opacity-90", className)} ))
{...props} ToastDescription.displayName = ToastPrimitives.Description.displayName
/>
)) type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastActionElement = React.ReactElement<typeof ToastAction>
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+30 -31
ファイルの表示
@@ -1,31 +1,30 @@
'use client' 'use client'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { Toast, import { Toast,
ToastClose, ToastClose,
ToastDescription, ToastDescription,
ToastProvider, ToastProvider,
ToastTitle, ToastTitle,
ToastViewport } from '@/components/ui/toast' ToastViewport } from '@/components/ui/toast'
export const Toaster = () => { export const Toaster = () => {
const { toasts } = useToast () const { toasts } = useToast ()
return ( return (
<ToastProvider> <ToastProvider>
{toasts.map (({ id, title, description, action, ...props }) => ( {toasts.map (({ id, title, description, action, ...props }) => (
<Toast key={id} <Toast key={id}
className="bg-gray-300/80 dark:bg-gray-700/80" className="bg-gray-300/80 dark:bg-gray-700/80"
{...props}> {...props}>
<div className="grid gap-1"> <div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>} {title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>} {description && <ToastDescription>{description}</ToastDescription>}
</div> </div>
{action} {action}
<ToastClose /> <ToastClose />
</Toast>))} </Toast>))}
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
) )
}
+28 -42
ファイルの表示
@@ -58,8 +58,7 @@ const addToRemoveQueue = (toastId: string) => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId)
dispatch({ dispatch({
type: "REMOVE_TOAST", type: "REMOVE_TOAST",
toastId: toastId, toastId: toastId})
})
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout)
@@ -69,17 +68,14 @@ export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case "ADD_TOAST": case "ADD_TOAST":
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)}
}
case "UPDATE_TOAST": case "UPDATE_TOAST":
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t t.id === action.toast.id ? { ...t, ...action.toast } : t)}
),
}
case "DISMISS_TOAST": { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action
@@ -87,36 +83,31 @@ export const reducer = (state: State, action: Action): State => {
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId)
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id)
}) })
} }
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined t.id === toastId || toastId === undefined
? { ? {
...t, ...t,
open: false, open: false}
} : t)}
: t
),
}
} }
case "REMOVE_TOAST": case "REMOVE_TOAST":
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: []}
}
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter((t) => t.id !== action.toastId)}
}
} }
} }
@@ -139,8 +130,7 @@ function toast({ ...props }: Toast) {
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: "UPDATE_TOAST", type: "UPDATE_TOAST",
toast: { ...props, id }, toast: { ...props, id }})
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({ dispatch({
@@ -150,16 +140,13 @@ function toast({ ...props }: Toast) {
id, id,
open: true, open: true,
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss() if (!open) dismiss()
}, }}})
},
})
return { return {
id: id, id: id,
dismiss, dismiss,
update, update}
}
} }
function useToast() { function useToast() {
@@ -170,7 +157,7 @@ function useToast() {
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState)
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1)
} }
} }
}, [state]) }, [state])
@@ -178,8 +165,7 @@ function useToast() {
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId })}
}
} }
export { useToast, toast } export { useToast, toast }
+1 -2
ファイルの表示
@@ -3,8 +3,7 @@ const ENV: string = 'development'
const config = { const config = {
API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002', API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002',
SITE_TITLE: 'ぼざクリ タグ広場' SITE_TITLE: 'ぼざクリ タグ広場'}
}
export const API_BASE_URL = config.API_BASE_URL export const API_BASE_URL = config.API_BASE_URL
export const SITE_TITLE = config.SITE_TITLE export const SITE_TITLE = config.SITE_TITLE
+5 -10
ファイルの表示
@@ -10,8 +10,7 @@ export const CATEGORIES = [
'general', 'general',
'material', 'material',
'meta', 'meta',
'nico', 'nico'] as const
] as const
export const CATEGORY_NAMES: Record<Category, string> = { export const CATEGORY_NAMES: Record<Category, string> = {
deerjikist: 'ニジラー', deerjikist: 'ニジラー',
@@ -20,16 +19,14 @@ export const CATEGORY_NAMES: Record<Category, string> = {
general: '一般', general: '一般',
material: '素材', material: '素材',
meta: 'メタタグ', meta: 'メタタグ',
nico: 'ニコニコタグ', nico: 'ニコニコタグ'} as const
} as const
export const FETCH_POSTS_ORDER_FIELDS = [ export const FETCH_POSTS_ORDER_FIELDS = [
'title', 'title',
'url', 'url',
'original_created_at', 'original_created_at',
'created_at', 'created_at',
'updated_at', 'updated_at'] as const
] as const
export const PLATFORMS = ['nico', 'youtube'] as const export const PLATFORMS = ['nico', 'youtube'] as const
@@ -43,13 +40,11 @@ export const TAG_COLOUR = {
general: 'cyan', general: 'cyan',
material: 'orange', material: 'orange',
meta: 'yellow', meta: 'yellow',
nico: 'gray', nico: 'gray'} as const satisfies Record<Category, string>
} as const satisfies Record<Category, string>
export const USER_ROLES = ['admin', 'member', 'guest'] as const export const USER_ROLES = ['admin', 'member', 'guest'] as const
export const ViewFlagBehavior = { export const ViewFlagBehavior = {
OnShowedDetail: 1, OnShowedDetail: 1,
OnClickedLink: 2, OnClickedLink: 2,
NotAuto: 3, NotAuto: 3} as const
} as const
+6 -12
ファイルの表示
@@ -23,8 +23,7 @@ const apiP = async <T> (
method: 'post' | 'put' | 'patch', method: 'post' | 'put' | 'patch',
path: string, path: string,
body?: unknown, body?: unknown,
opt?: Opt, opt?: Opt): Promise<T> => {
): Promise<T> => {
const res = await client[method] (path, body ?? { }, withUserCode (opt)) const res = await client[method] (path, body ?? { }, withUserCode (opt))
if (opt?.responseType === 'blob') if (opt?.responseType === 'blob')
return res.data as T return res.data as T
@@ -34,8 +33,7 @@ const apiP = async <T> (
export const apiGet = async <T> ( export const apiGet = async <T> (
path: string, path: string,
opt?: Opt, opt?: Opt): Promise<T> => {
): Promise<T> => {
const res = await client.get (path, withUserCode (opt)) const res = await client.get (path, withUserCode (opt))
if (opt?.responseType === 'blob') if (opt?.responseType === 'blob')
return res.data as T return res.data as T
@@ -46,28 +44,24 @@ export const apiGet = async <T> (
export const apiPost = async <T> ( export const apiPost = async <T> (
path: string, path: string,
body?: unknown, body?: unknown,
opt?: Opt, opt?: Opt): Promise<T> => apiP ('post', path, body, opt)
): Promise<T> => apiP ('post', path, body, opt)
export const apiPut = async <T> ( export const apiPut = async <T> (
path: string, path: string,
body?: unknown, body?: unknown,
opt?: Opt, opt?: Opt): Promise<T> => apiP ('put', path, body, opt)
): Promise<T> => apiP ('put', path, body, opt)
export const apiPatch = async <T> ( export const apiPatch = async <T> (
path: string, path: string,
body?: unknown, body?: unknown,
opt?: Opt, opt?: Opt): Promise<T> => apiP ('patch', path, body, opt)
): Promise<T> => apiP ('patch', path, body, opt)
export const apiDelete = async <T = void> ( export const apiDelete = async <T = void> (
path: string, path: string,
opt?: Opt, opt?: Opt): Promise<T> => {
): Promise<T> => {
const res = await client.delete (path, withUserCode (opt)) const res = await client.delete (path, withUserCode (opt))
if (res.data == null || res.data === '') if (res.data == null || res.data === '')
return undefined as T return undefined as T
+14 -28
ファイルの表示
@@ -104,8 +104,7 @@ export type BuildGekanatorQuestionsOptions = {
export const normalizeTitleLengthCondition = ( export const normalizeTitleLengthCondition = (
condition: GekanatorQuestionCondition, condition: GekanatorQuestionCondition): GekanatorQuestionCondition => {
): GekanatorQuestionCondition => {
switch (condition.type) switch (condition.type)
{ {
case 'title-length-greater-than': case 'title-length-greater-than':
@@ -119,8 +118,7 @@ export const normalizeTitleLengthCondition = (
export const titleLengthMinimumForCondition = ( export const titleLengthMinimumForCondition = (
condition: GekanatorQuestionCondition, condition: GekanatorQuestionCondition): number | null => {
): number | null => {
switch (condition.type) switch (condition.type)
{ {
case 'title-length-at-least': case 'title-length-at-least':
@@ -134,8 +132,7 @@ export const titleLengthMinimumForCondition = (
export const questionIdForCondition = ( export const questionIdForCondition = (
condition: NonPostSimilarityCondition, condition: NonPostSimilarityCondition): string => {
): string => {
switch (condition.type) switch (condition.type)
{ {
case 'tag': case 'tag':
@@ -161,8 +158,7 @@ export const questionIdForCondition = (
const directExampleAnswerFor = ( const directExampleAnswerFor = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion,
post: Post, post: Post): GekanatorAnswerValue | null => {
): GekanatorAnswerValue | null => {
if (question.kind !== 'post_similarity' && question.kind !== 'tag') if (question.kind !== 'post_similarity' && question.kind !== 'tag')
return null return null
@@ -178,15 +174,13 @@ const directExampleAnswerFor = (
export const isLearnedSemanticQuestion = ( export const isLearnedSemanticQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion, question: StoredGekanatorQuestion | GekanatorQuestion): boolean =>
): boolean =>
question.kind === 'post_similarity' question.kind === 'post_similarity'
&& question.source === 'user_suggested' && question.source === 'user_suggested'
export const learnedSemanticSideForAnswer = ( export const learnedSemanticSideForAnswer = (
answer: GekanatorAnswerValue | null, answer: GekanatorAnswerValue | null): LearnedSemanticSide => {
): LearnedSemanticSide => {
if (answer === 'yes' || answer === 'partial') if (answer === 'yes' || answer === 'partial')
return 'positive' return 'positive'
@@ -314,8 +308,7 @@ const questionableTag = (post: Post, key: string): boolean => {
const questionMatches = ( const questionMatches = (
post: Post, post: Post,
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion): boolean => {
): boolean => {
const directAnswer = directExampleAnswerFor (question, post) const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer) if (directAnswer)
return question.kind === 'post_similarity' return question.kind === 'post_similarity'
@@ -350,8 +343,7 @@ const questionMatches = (
export const expectedAnswerForQuestion = ( export const expectedAnswerForQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined, question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null, post: Post | null): GekanatorAnswerValue | null => {
): GekanatorAnswerValue | null => {
if (!(question) || !(post)) if (!(question) || !(post))
return null return null
@@ -382,14 +374,12 @@ export const expectedAnswerForQuestion = (
export const learnedSemanticSideForPost = ( export const learnedSemanticSideForPost = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined, question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null, post: Post | null): LearnedSemanticSide =>
): LearnedSemanticSide =>
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post)) learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
export const restoreGekanatorQuestion = ( export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion): GekanatorQuestion => {
): GekanatorQuestion => {
const normalizedCondition = normalizeTitleLengthCondition (question.condition) const normalizedCondition = normalizeTitleLengthCondition (question.condition)
const normalizedQuestion = { const normalizedQuestion = {
...question, ...question,
@@ -408,8 +398,7 @@ export const restoreGekanatorQuestion = (
export const storeGekanatorQuestion = ( export const storeGekanatorQuestion = (
question: GekanatorQuestion, question: GekanatorQuestion): StoredGekanatorQuestion => ({
): StoredGekanatorQuestion => ({
id: question.condition.type === 'title-length-greater-than' id: question.condition.type === 'title-length-greater-than'
? `title:length-at-least:${ question.condition.length + 1 }` ? `title:length-at-least:${ question.condition.length + 1 }`
: question.id, : question.id,
@@ -436,8 +425,7 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
export const fetchGekanatorExtraQuestions = async ( export const fetchGekanatorExtraQuestions = async (
gameId: number, gameId: number,
nonce?: string, nonce?: string): Promise<GekanatorExtraQuestion[]> => {
): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> ( const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`, `/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined }) { params: nonce ? { nonce } : undefined })
@@ -447,8 +435,7 @@ export const fetchGekanatorExtraQuestions = async (
export const buildGekanatorQuestions = ( export const buildGekanatorQuestions = (
posts: Post[], posts: Post[],
options: BuildGekanatorQuestionsOptions = { }, options: BuildGekanatorQuestionsOptions = { }): GekanatorQuestion[] => {
): GekanatorQuestion[] => {
const { const {
includeTitleContains = true, includeTitleContains = true,
tagQuestionCap = 192, tagQuestionCap = 192,
@@ -490,8 +477,7 @@ export const buildGekanatorQuestions = (
const usefulEntries = <T extends string | number> ( const usefulEntries = <T extends string | number> (
counts: Map<T, number>, counts: Map<T, number>,
cap: number, cap: number) =>
) =>
[...counts.entries ()] [...counts.entries ()]
.filter (([, count]) => count > 0 && count < posts.length) .filter (([, count]) => count > 0 && count < posts.length)
.sort ((a, b) => Math.abs (posts.length / 2 - a[1]) .sort ((a, b) => Math.abs (posts.length / 2 - a[1])
+4 -8
ファイルの表示
@@ -32,8 +32,7 @@ export const candidatePostsFor = (
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string> softenedQuestionIds: Set<string>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }, recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): Post[] => {
): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question])) const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => { return posts.filter (post => {
@@ -76,8 +75,7 @@ export const candidatePostsFor = (
export const hardFilteredPostsForAnswer = ( export const hardFilteredPostsForAnswer = (
{ posts, question, answer }: { posts: Post[] { posts, question, answer }: { posts: Post[]
question: GekanatorQuestion question: GekanatorQuestion
answer: GekanatorAnswerValue }, answer: GekanatorAnswerValue }): Post[] => {
): Post[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question))) if (!(questionSupportsAnswerBasedHardFiltering (question)))
return posts return posts
@@ -98,8 +96,7 @@ const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', '
export const allConcreteAnswerOptionsExhausted = ( export const allConcreteAnswerOptionsExhausted = (
posts: Post[], posts: Post[],
question: GekanatorQuestion | null, question: GekanatorQuestion | null): boolean => {
): boolean => {
if (!(question)) if (!(question))
return false return false
@@ -125,8 +122,7 @@ export const recoverCandidatePosts = (
recoveredCandidatePosts: Map<number, RecoveredCandidateState> recoveredCandidatePosts: Map<number, RecoveredCandidateState>
eligiblePostIds: Set<number> eligiblePostIds: Set<number>
answerCountAtRecovery: number answerCountAtRecovery: number
recoveryStepCount: number }, recoveryStepCount: number }): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number } | null => { recoveryStepCount: number } | null => {
const recovered = new Map (recoveredCandidatePosts) const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount) const targetSize = nextRecoveryTargetSize (recoveryStepCount)
+8 -16
ファイルの表示
@@ -8,8 +8,7 @@ import type {
export const monthForCondition = ( export const monthForCondition = (
condition: GekanatorQuestion['condition'], condition: GekanatorQuestion['condition']): number | null => {
): number | null => {
if (condition.type === 'original-month') if (condition.type === 'original-month')
return condition.month return condition.month
@@ -24,8 +23,7 @@ export const monthForCondition = (
const isTitleLengthContradiction = ( const isTitleLengthContradiction = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue, answer: GekanatorAnswerValue): boolean => {
): boolean => {
const candidateLength = titleLengthMinimumForCondition (candidate) const candidateLength = titleLengthMinimumForCondition (candidate)
const previousLength = titleLengthMinimumForCondition (previous) const previousLength = titleLengthMinimumForCondition (previous)
if (candidateLength === null || previousLength === null) if (candidateLength === null || previousLength === null)
@@ -45,8 +43,7 @@ const isTitleLengthContradiction = (
const isQuestionRedundantAfterAnswers = ( const isQuestionRedundantAfterAnswers = (
question: GekanatorQuestion, question: GekanatorQuestion,
answers: GekanatorAnswerLog[], answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
): boolean => answers.some (answer => {
const previous = answer.questionCondition const previous = answer.questionCondition
return previous !== undefined return previous !== undefined
&& isTitleLengthContradiction (question.condition, previous, answer.answer) && isTitleLengthContradiction (question.condition, previous, answer.answer)
@@ -56,8 +53,7 @@ const isQuestionRedundantAfterAnswers = (
const isSourceFactBlocked = ( const isSourceFactBlocked = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue, answer: GekanatorAnswerValue): boolean => {
): boolean => {
if (candidate.type !== 'source' || previous.type !== 'source') if (candidate.type !== 'source' || previous.type !== 'source')
return false return false
@@ -76,8 +72,7 @@ const isSourceFactBlocked = (
const isOriginalYearFactBlocked = ( const isOriginalYearFactBlocked = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue, answer: GekanatorAnswerValue): boolean => {
): boolean => {
if (candidate.type !== 'original-year' || previous.type !== 'original-year') if (candidate.type !== 'original-year' || previous.type !== 'original-year')
return false return false
@@ -96,8 +91,7 @@ const isOriginalYearFactBlocked = (
const isOriginalMonthFactBlocked = ( const isOriginalMonthFactBlocked = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue, answer: GekanatorAnswerValue): boolean => {
): boolean => {
switch (answer) switch (answer)
{ {
case 'yes': case 'yes':
@@ -143,8 +137,7 @@ const isOriginalMonthFactBlocked = (
const isFactQuestionBlocked = ( const isFactQuestionBlocked = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue, answer: GekanatorAnswerValue): boolean => {
): boolean => {
if (!(answer === 'yes' || answer === 'no')) if (!(answer === 'yes' || answer === 'no'))
return false return false
@@ -156,8 +149,7 @@ const isFactQuestionBlocked = (
export const isQuestionHardFilteredAfterAnswers = ( export const isQuestionHardFilteredAfterAnswers = (
question: GekanatorQuestion, question: GekanatorQuestion,
answers: GekanatorAnswerLog[], answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
): boolean => answers.some (answer => {
const previous = answer.questionCondition const previous = answer.questionCondition
if (previous === undefined) if (previous === undefined)
return false return false
+107
ファイルの表示
@@ -0,0 +1,107 @@
import {
apiGet,
isApiError,
apiPatch,
apiPost,
apiPut,
} from '@/lib/api'
import type {
Material,
FetchMaterialsParams,
MaterialFilter,
MaterialSidebarTag,
MaterialTagTree,
} from '@/types'
export type FetchMaterialTreeParams = {
parentId?: number | null
materialFilter: MaterialFilter
}
export type MaterialIndexResponse = {
materials: Material[]
count: number
}
const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
export const parseMaterialFilter = (
value: unknown,
fallback: MaterialFilter = 'present'): MaterialFilter =>
typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter)
? value as MaterialFilter
: fallback
export const fetchMaterials = async (
{ q, tagState, mediaKind, suppression, createdFrom, createdTo,
updatedFrom, updatedTo, sort, direction, page, limit }: FetchMaterialsParams): Promise<MaterialIndexResponse> =>
await apiGet ('/materials', { params: {
...(q && { q }),
tag_state: tagState,
media_kind: mediaKind,
suppression,
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
...(updatedFrom && { updated_from: updatedFrom }),
...(updatedTo && { updated_to: updatedTo }),
sort,
direction,
page,
limit} })
export const fetchMaterial = async (id: string): Promise<Material | null> => {
try
{
return await apiGet (`/materials/${ id }`)
}
catch (error)
{
if (isApiError (error) && error.response?.status === 404)
return null
throw error
}
}
export const fetchMaterialTagTree = async (
{ parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> =>
await apiGet ('/tags/with-depth', { params: {
...(parentId != null && { parent: String (parentId) }),
material_filter: materialFilter} })
export const fetchMaterialTagByName = async (
name: string,
materialFilter: MaterialFilter): Promise<MaterialTagTree | null> => {
try
{
return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`,
{ params: { material_filter: materialFilter } })
}
catch (error)
{
if (isApiError (error) && error.response?.status === 404)
return null
throw error
}
}
export const createMaterial = async (formData: FormData): Promise<Material> =>
await apiPost ('/materials', formData)
export const updateMaterial = async (
id: string,
formData: FormData): Promise<Material> =>
await apiPut (`/materials/${ id }`, formData)
export const suppressMaterialFile = async (
id: string,
payload: { reason: string; purge?: boolean }): Promise<Material> =>
await apiPatch (`/materials/${ id }/suppress_file`, payload)
+3 -6
ファイルの表示
@@ -5,8 +5,7 @@ import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async ( export const fetchPosts = async (
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo, { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams, originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams): Promise<{
): Promise<{
posts: Post[] posts: Post[]
count: number }> => count: number }> =>
await apiGet ('/posts', { params: { await apiGet ('/posts', { params: {
@@ -33,8 +32,7 @@ export const fetchPostChanges = async (
post?: string post?: string
tag?: string tag?: string
page: number page: number
limit: number }, limit: number }): Promise<{
): Promise<{
versions: PostVersion[] versions: PostVersion[]
count: number }> => count: number }> =>
await apiGet ('/posts/versions', { params: { ...(post && { post }), await apiGet ('/posts/versions', { params: { ...(post && { post }),
@@ -52,8 +50,7 @@ export const updatePost = async (
{ baseVersionNo, force, merge }: { { baseVersionNo, force, merge }: {
baseVersionNo?: number baseVersionNo?: number
force?: boolean force?: boolean
merge?: boolean } merge?: boolean }) =>
) =>
await apiPut<Post> ( await apiPut<Post> (
`/posts/${ post.id }`, `/posts/${ post.id }`,
{ title: post.title, { title: post.title,
+20 -1
ファイルの表示
@@ -1,4 +1,10 @@
import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types' import type {
FetchNicoTagsParams,
FetchMaterialsParams,
FetchPostsParams,
FetchTagsParams,
MaterialFilter,
} from '@/types'
export const postsKeys = { export const postsKeys = {
root: ['posts'] as const, root: ['posts'] as const,
@@ -25,6 +31,19 @@ export const tagsKeys = {
['tags', 'changes', p] as const, ['tags', 'changes', p] as const,
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const } deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }
export const materialsKeys = {
root: ['materials'] as const,
index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const,
byTagName: (name: string, materialFilter: MaterialFilter) =>
['materials', 'tag', name, materialFilter] as const,
show: (id: string) => ['materials', id] as const,
tree: (p: {
parentId?: number | null
materialFilter: MaterialFilter
}) => ['materials', 'tree', p] as const,
unclassified: (p: { page?: number; limit?: number } = { }) =>
['materials', 'unclassified', p] as const}
export const wikiKeys = { export const wikiKeys = {
root: ['wiki'] as const, root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const, index: (p: { title?: string }) => ['wiki', 'index', p] as const,
+4 -8
ファイルの表示
@@ -11,8 +11,7 @@ import type { Deerjikist,
export const fetchTags = async ( export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, deprecated, updatedFrom, updatedTo, deprecated,
page, limit, order }: FetchTagsParams, page, limit, order }: FetchTagsParams): Promise<{ tags: Tag[]
): Promise<{ tags: Tag[]
count: number }> => count: number }> =>
await apiGet ('/tags', { params: { await apiGet ('/tags', { params: {
...(post != null && { post }), ...(post != null && { post }),
@@ -31,8 +30,7 @@ export const fetchTags = async (
export const fetchNicoTags = async ( export const fetchNicoTags = async (
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams, { name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams): Promise<{ tags: NicoTag[]
): Promise<{ tags: NicoTag[]
count: number }> => count: number }> =>
await apiGet ('/tags/nico', { params: { await apiGet ('/tags/nico', { params: {
page, page,
@@ -70,14 +68,12 @@ export const fetchTagChanges = async (
{ id, page, limit }: { { id, page, limit }: {
id?: string id?: string
page: number page: number
limit: number }, limit: number }): Promise<{
): Promise<{
versions: TagVersion[] versions: TagVersion[]
count: number }> => count: number }> =>
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } })
export const fetchDeerjikistsByTag = async ( export const fetchDeerjikistsByTag = async (
id: string, id: string): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
): Promise<{ tag: Tag; deerjikists: Deerjikist[]}> =>
await apiGet (`/tags/${ id }/deerjikists`) await apiGet (`/tags/${ id }/deerjikists`)
+1 -2
ファイルの表示
@@ -4,5 +4,4 @@ const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
export const canEditContent = ( export const canEditContent = (
user: Pick<User, 'role'> | null | undefined, user: Pick<User, 'role'> | null | undefined): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
+2 -4
ファイルの表示
@@ -12,8 +12,7 @@ export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))
export const dateString = ( export const dateString = (
d: string | Date, d: string | Date,
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null, unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null): string =>
): string =>
toDate (d).toLocaleString ( toDate (d).toLocaleString (
'ja-JP-u-ca-japanese', 'ja-JP-u-ca-japanese',
{ era: 'long', { era: 'long',
@@ -28,8 +27,7 @@ export const dateString = (
export const originalCreatedAtString = ( export const originalCreatedAtString = (
f: string | Date | null, f: string | Date | null,
b: string | Date | null, b: string | Date | null): string => {
): string => {
const from = f ? toDate (f) : null const from = f ? toDate (f) : null
const before = b ? toDate (b) : null const before = b ? toDate (b) : null
+3 -6
ファイルの表示
@@ -4,22 +4,19 @@ import type { WikiPage } from '@/types'
export const fetchWikiPages = async ( export const fetchWikiPages = async (
{ title }: { title?: string }, { title }: { title?: string }): Promise<WikiPage[]> =>
): Promise<WikiPage[]> =>
await apiGet ('/wiki', { params: { title } }) await apiGet ('/wiki', { params: { title } })
export const fetchWikiPage = async ( export const fetchWikiPage = async (
id: string, id: string,
{ version }: { version?: string }, { version }: { version?: string }): Promise<WikiPage> =>
): Promise<WikiPage> =>
await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } }) await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } })
export const fetchWikiPageByTitle = async ( export const fetchWikiPageByTitle = async (
title: string, title: string,
{ version }: { version?: string }, { version }: { version?: string }): Promise<WikiPage | null> => {
): Promise<WikiPage | null> => {
try try
{ {
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } }) return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
+73 -146
ファイルの表示
@@ -238,8 +238,7 @@ const createGameSeed = (): string => {
const normalizeStoredQuestionId = ( const normalizeStoredQuestionId = (
questionId: string, questionId: string,
condition?: GekanatorQuestionCondition, condition?: GekanatorQuestionCondition): string => {
): string => {
if (condition?.type === 'title-length-greater-than') if (condition?.type === 'title-length-greater-than')
return `title:length-at-least:${ condition.length + 1 }` return `title:length-at-least:${ condition.length + 1 }`
@@ -312,8 +311,7 @@ const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
const shouldReplaceMergedQuestion = ( const shouldReplaceMergedQuestion = (
current: GekanatorQuestion | undefined, current: GekanatorQuestion | undefined,
candidate: GekanatorQuestion, candidate: GekanatorQuestion): boolean => {
): boolean => {
if (!(current)) if (!(current))
return true return true
@@ -408,8 +406,7 @@ const loadRecentGames = (): RecentGameSummary[] => {
const storeRecentGameSummary = ( const storeRecentGameSummary = (
summary: RecentGameSummary, summary: RecentGameSummary): RecentGameSummary[] => {
): RecentGameSummary[] => {
const next = const next =
[summary, [summary,
...loadRecentGames ().filter (item => (item.savedAt !== summary.savedAt ...loadRecentGames ().filter (item => (item.savedAt !== summary.savedAt
@@ -459,8 +456,7 @@ const resettableExtraQuestionState = (): {
const recoveredCandidateMapFromStored = ( const recoveredCandidateMapFromStored = (
items: RecoveredCandidatePost[], items: RecoveredCandidatePost[],
scores: [number, number][], scores: [number, number][]): Map<number, RecoveredCandidateState> => {
): Map<number, RecoveredCandidateState> => {
const storedScores = new Map (scores) const storedScores = new Map (scores)
return new Map (items.map (item => [item.postId, { return new Map (items.map (item => [item.postId, {
@@ -470,8 +466,7 @@ const recoveredCandidateMapFromStored = (
const storedRecoveredCandidatesFromMap = ( const storedRecoveredCandidatesFromMap = (
recoveredCandidatePosts: Map<number, RecoveredCandidateState>, recoveredCandidatePosts: Map<number, RecoveredCandidateState>): RecoveredCandidatePost[] =>
): RecoveredCandidatePost[] =>
[...recoveredCandidatePosts.entries ()] [...recoveredCandidatePosts.entries ()]
.map (([postId, recoveredCandidate]) => ({ .map (([postId, recoveredCandidate]) => ({
postId, postId,
@@ -513,8 +508,7 @@ const distributionEntropy = (weights: number[]): number =>
const questionCategoryPenalty = ( const questionCategoryPenalty = (
question: GekanatorQuestion, question: GekanatorQuestion,
answerCount: number, answerCount: number,
repeatPenalty: number, repeatPenalty: number): number => {
): number => {
const earlyFactor = Math.max (0, (3 - answerCount) / 3) const earlyFactor = Math.max (0, (3 - answerCount) / 3)
const titleLengthPenalty = (() => { const titleLengthPenalty = (() => {
if (titleLengthMinimumForCondition (question.condition) == null) if (titleLengthMinimumForCondition (question.condition) == null)
@@ -553,8 +547,7 @@ const relatedPostIdsOf = (post: Post): number[] => {
const userPriorWeightsFor = ( const userPriorWeightsFor = (
posts: Post[], posts: Post[],
recentGames: RecentGameSummary[], recentGames: RecentGameSummary[]): Map<number, number> => {
): Map<number, number> => {
const postById = new Map (posts.map (post => [post.id, post])) const postById = new Map (posts.map (post => [post.id, post]))
const weights = new Map<number, number> () const weights = new Map<number, number> ()
const addWeight = (postId: number, weight: number) => { const addWeight = (postId: number, weight: number) => {
@@ -581,14 +574,12 @@ const userPriorWeightsFor = (
const answerWeightFor = ( const answerWeightFor = (
questionId: string, questionId: string,
softenedQuestionIds: Set<string>, softenedQuestionIds: Set<string>): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
const scoreWeightForAnswer = ( const scoreWeightForAnswer = (
answer: GekanatorAnswerLog, answer: GekanatorAnswerLog,
softenedQuestionIds: Set<string>, softenedQuestionIds: Set<string>): number =>
): number =>
answerWeightFor (answer.questionId, softenedQuestionIds) answerWeightFor (answer.questionId, softenedQuestionIds)
* ( * (
answer.questionPurpose === 'learning_user_suggested' answer.questionPurpose === 'learning_user_suggested'
@@ -638,8 +629,7 @@ const titleTermPattern =
const addPostIdToIndex = <K extends string | number> ( const addPostIdToIndex = <K extends string | number> (
index: Map<K, Set<number>>, index: Map<K, Set<number>>,
key: K, key: K,
postId: number, postId: number) => {
) => {
const current = index.get (key) const current = index.get (key)
if (current) if (current)
{ {
@@ -652,8 +642,7 @@ const addPostIdToIndex = <K extends string | number> (
const buildMaterialIndex = ( const buildMaterialIndex = (
posts: Post[], posts: Post[]): GekanatorQuestionMaterialIndex => {
): GekanatorQuestionMaterialIndex => {
const postById = new Map<number, Post> () const postById = new Map<number, Post> ()
const tagKeysByPostId = new Map<number, string[]> () const tagKeysByPostId = new Map<number, string[]> ()
const postIdsByTagKey = new Map<string, Set<number>> () const postIdsByTagKey = new Map<string, Set<number>> ()
@@ -784,8 +773,7 @@ const originalDateQuestionTextFor = (
condition: Extract< condition: Extract<
GekanatorQuestionCondition, GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' } { type: 'original-year' | 'original-month' | 'original-month-day' }
>, >): string => {
): string => {
switch (condition.type) switch (condition.type)
{ {
case 'original-year': case 'original-year':
@@ -852,8 +840,7 @@ const isLearnableTagKey = (key: string): boolean => !(key.startsWith ('nico:'))
const isUserSuggestedLearnedSemanticQuestion = ( const isUserSuggestedLearnedSemanticQuestion = (
question: GekanatorQuestion, question: GekanatorQuestion): boolean => isLearnedSemanticQuestion (question)
): boolean => isLearnedSemanticQuestion (question)
type LearnedSemanticCandidateStats = { type LearnedSemanticCandidateStats = {
@@ -872,8 +859,7 @@ const learnedSemanticStatsForCandidateIds = (
question }: { question }: {
candidateIds: number[] candidateIds: number[]
posts: Post[] posts: Post[]
question: GekanatorQuestion }, question: GekanatorQuestion }): LearnedSemanticCandidateStats => {
): LearnedSemanticCandidateStats => {
const candidateIdSet = new Set (candidateIds) const candidateIdSet = new Set (candidateIds)
const positiveIds = new Set<number> () const positiveIds = new Set<number> ()
const negativeIds = new Set<number> () const negativeIds = new Set<number> ()
@@ -915,8 +901,7 @@ const learnedSemanticQuestionIsEffectiveForCandidateIds = (
question }: { question }: {
candidateIds: number[] candidateIds: number[]
posts: Post[] posts: Post[]
question: GekanatorQuestion }, question: GekanatorQuestion }): boolean => {
): boolean => {
if (!(isUserSuggestedLearnedSemanticQuestion (question))) if (!(isUserSuggestedLearnedSemanticQuestion (question)))
return false return false
@@ -936,8 +921,7 @@ const learnedSemanticQuestionIsEffectiveForCandidateIds = (
const directSemanticAnswerForPost = ( const directSemanticAnswerForPost = (
question: GekanatorQuestion, question: GekanatorQuestion,
post: Post, post: Post): GekanatorAnswerValue | null => {
): GekanatorAnswerValue | null => {
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
if (direct) if (direct)
return direct return direct
@@ -956,8 +940,7 @@ const learnedSemanticLearningValueForTopPosts = (
question: GekanatorQuestion question: GekanatorQuestion
learningTargetPosts: Post[] learningTargetPosts: Post[]
candidateIds: number[] candidateIds: number[]
posts: Post[] }, posts: Post[] }): { missingTopCount: number
): { missingTopCount: number
knownCount: number knownCount: number
hasLearningValue: boolean } => { hasLearningValue: boolean } => {
const missingTopCount = const missingTopCount =
@@ -1000,8 +983,7 @@ const learningTargetPostsForCandidates = ({
const questionPurposeCountsFor = ( const questionPurposeCountsFor = (
answers: GekanatorAnswerLog[], answers: GekanatorAnswerLog[]): {
): {
effectiveUserSuggestedCount: number effectiveUserSuggestedCount: number
learningUserSuggestedCount: number learningUserSuggestedCount: number
normalQuestionCount: number normalQuestionCount: number
@@ -1041,8 +1023,7 @@ const questionPurposeCountsFor = (
const learnedSemanticNarrowPenaltyForStats = ( const learnedSemanticNarrowPenaltyForStats = (
candidateCount: number, candidateCount: number,
stats: LearnedSemanticCandidateStats, stats: LearnedSemanticCandidateStats): number => {
): number => {
const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08) const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08)
return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0 return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0
} }
@@ -1050,8 +1031,7 @@ const learnedSemanticNarrowPenaltyForStats = (
const learnedSemanticScoreDeltaForExpectedAnswer = ( const learnedSemanticScoreDeltaForExpectedAnswer = (
userAnswer: GekanatorAnswerValue, userAnswer: GekanatorAnswerValue,
expectedAnswer: GekanatorAnswerValue | null, expectedAnswer: GekanatorAnswerValue | null): number => {
): number => {
switch (userAnswer) switch (userAnswer)
{ {
case 'yes': case 'yes':
@@ -1089,8 +1069,7 @@ const learnedSemanticScoreDeltaForExpectedAnswer = (
const scoreDropDeltaForRecoveredPost = ( const scoreDropDeltaForRecoveredPost = (
postId: number, postId: number,
totalScore: number, totalScore: number,
recoveredCandidatePosts: Map<number, RecoveredCandidateState>, recoveredCandidatePosts: Map<number, RecoveredCandidateState>): number => {
): number => {
const recoveredCandidate = recoveredCandidatePosts.get (postId) const recoveredCandidate = recoveredCandidatePosts.get (postId)
if (recoveredCandidate == null) if (recoveredCandidate == null)
return totalScore return totalScore
@@ -1114,8 +1093,7 @@ const postPassesScoreDrop = (
recoveredCandidatePosts }: { recoveredCandidatePosts }: {
postId: number postId: number
scores: Map<number, number> scores: Map<number, number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }, recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): boolean => {
): boolean => {
if (!(activeCandidateScoreDropEnabled (scores))) if (!(activeCandidateScoreDropEnabled (scores)))
return true return true
@@ -1129,8 +1107,7 @@ const postPassesScoreDrop = (
// `post_similarities` is the score-propagation graph, not the question kind. // `post_similarities` is the score-propagation graph, not the question kind.
const questionUsesPostSimilarityPropagationGraphForScoring = ( const questionUsesPostSimilarityPropagationGraphForScoring = (
question: GekanatorQuestion, question: GekanatorQuestion): boolean =>
): boolean =>
(question.kind === 'post_similarity' (question.kind === 'post_similarity'
&& !(isUserSuggestedLearnedSemanticQuestion (question))) && !(isUserSuggestedLearnedSemanticQuestion (question)))
|| (question.kind === 'tag' || (question.kind === 'tag'
@@ -1139,8 +1116,7 @@ const questionUsesPostSimilarityPropagationGraphForScoring = (
const questionSupportsAnswerBasedHardFiltering = ( const questionSupportsAnswerBasedHardFiltering = (
question: GekanatorQuestion, question: GekanatorQuestion): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
&& !(isUserSuggestedLearnedSemanticQuestion (question)) && !(isUserSuggestedLearnedSemanticQuestion (question))
@@ -1160,8 +1136,7 @@ const usesLearnedTagExamples = (question: GekanatorQuestion): boolean =>
const searchedQuestionsFor = ( const searchedQuestionsFor = (
questions: GekanatorQuestion[], questions: GekanatorQuestion[],
search: string, search: string): GekanatorQuestion[] => {
): GekanatorQuestion[] => {
const needle = search.trim () const needle = search.trim ()
if (!(needle)) if (!(needle))
return [] return []
@@ -1235,8 +1210,7 @@ type QuestionMatchResolver = {
const buildGekanatorMatchIndex = ( const buildGekanatorMatchIndex = (
posts: Post[], posts: Post[],
questions: GekanatorQuestion[], questions: GekanatorQuestion[]): GekanatorMatchIndex => new Map (
): GekanatorMatchIndex => new Map (
questions.map (question => [ questions.map (question => [
question.id, question.id,
new Set ( new Set (
@@ -1285,8 +1259,7 @@ const matchingPostIdsForQuestion = ({
const positiveMatchingPostIdsForQuestion = ( const positiveMatchingPostIdsForQuestion = (
resolver: QuestionMatchResolver, resolver: QuestionMatchResolver): Set<number> => {
): Set<number> => {
if (isUserSuggestedLearnedSemanticQuestion (resolver.question)) if (isUserSuggestedLearnedSemanticQuestion (resolver.question))
{ {
const cached = resolver.dynamicMatchIndex?.get (resolver.question.id) const cached = resolver.dynamicMatchIndex?.get (resolver.question.id)
@@ -1356,8 +1329,7 @@ const matchingWeightInCandidates = (
materialIndex: GekanatorQuestionMaterialIndex materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex matchIndex: GekanatorMatchIndex
question: GekanatorQuestion question: GekanatorQuestion
dynamicMatchIndex?: GekanatorMatchIndex }, dynamicMatchIndex?: GekanatorMatchIndex }): number => {
): number => {
const matched = positiveMatchingPostIdsForQuestion ({ const matched = positiveMatchingPostIdsForQuestion ({
posts, posts,
materialIndex, materialIndex,
@@ -1380,8 +1352,7 @@ const signatureForCandidateIds = (
materialIndex: GekanatorQuestionMaterialIndex materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex matchIndex: GekanatorMatchIndex
question: GekanatorQuestion question: GekanatorQuestion
dynamicMatchIndex?: GekanatorMatchIndex }, dynamicMatchIndex?: GekanatorMatchIndex }): string => {
): string => {
if (isUserSuggestedLearnedSemanticQuestion (question)) if (isUserSuggestedLearnedSemanticQuestion (question))
{ {
const postById = new Map (posts.map (post => [post.id, post])) const postById = new Map (posts.map (post => [post.id, post]))
@@ -1421,8 +1392,7 @@ const postIdsForHardAnswer = (
posts: Post[] posts: Post[]
materialIndex: GekanatorQuestionMaterialIndex materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex matchIndex: GekanatorMatchIndex
dynamicMatchIndex?: GekanatorMatchIndex }, dynamicMatchIndex?: GekanatorMatchIndex }): number[] => {
): number[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question))) if (!(questionSupportsAnswerBasedHardFiltering (question)))
return candidateIds return candidateIds
@@ -1562,8 +1532,7 @@ const buildIndexedQuestion = (
text: string text: string
kind: GekanatorQuestionKind kind: GekanatorQuestionKind
priorityWeight: number priorityWeight: number
materialIndex: GekanatorQuestionMaterialIndex }, materialIndex: GekanatorQuestionMaterialIndex }): GekanatorQuestion => ({
): GekanatorQuestion => ({
id: questionIdForCondition (condition), id: questionIdForCondition (condition),
text, text,
kind, kind,
@@ -1578,8 +1547,7 @@ const buildIndexedQuestion = (
const rankedEntriesForCounts = <T extends string | number> ( const rankedEntriesForCounts = <T extends string | number> (
{ counts, total, cap }: { counts: Map<T, number> { counts, total, cap }: { counts: Map<T, number>
total: number total: number
cap: number }, cap: number }): [T, number][] =>
): [T, number][] =>
([...counts.entries ()] ([...counts.entries ()]
.filter (([, count]) => count > 0 && count < total) .filter (([, count]) => count > 0 && count < total)
.sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1])) .sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1]))
@@ -1594,8 +1562,7 @@ const buildQuestionsForCandidateIds = (
materialIndex: GekanatorQuestionMaterialIndex materialIndex: GekanatorQuestionMaterialIndex
acceptedQuestions: GekanatorQuestion[] acceptedQuestions: GekanatorQuestion[]
mode?: QuestionBuildMode mode?: QuestionBuildMode
confirmationPostId?: number | null }, confirmationPostId?: number | null }): GekanatorQuestion[] => {
): GekanatorQuestion[] => {
const total = candidateIds.length const total = candidateIds.length
const confirmationPost = (() => { const confirmationPost = (() => {
if (confirmationPostId == null) if (confirmationPostId == null)
@@ -1652,8 +1619,7 @@ const buildQuestionsForCandidateIds = (
condition: Extract< condition: Extract<
GekanatorQuestionCondition, GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' } { type: 'original-year' | 'original-month' | 'original-month-day' }
>, >): GekanatorQuestion => {
): GekanatorQuestion => {
const priorityWeight = (() => { const priorityWeight = (() => {
if (condition.type === 'original-year') if (condition.type === 'original-year')
return 1.04 return 1.04
@@ -1673,8 +1639,7 @@ const buildQuestionsForCandidateIds = (
const specialMonthDays = rankedEntriesForCounts ({ const specialMonthDays = rankedEntriesForCounts ({
counts: monthDayCounts, counts: monthDayCounts,
total, total,
cap: factCap cap: factCap}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
if (mode === 'split') if (mode === 'split')
{ {
@@ -2124,8 +2089,7 @@ type ExclusiveConditionGroup =
const exclusiveConditionGroupFor = ( const exclusiveConditionGroupFor = (
condition: GekanatorQuestion['condition'], condition: GekanatorQuestion['condition']): ExclusiveConditionGroup | null => {
): ExclusiveConditionGroup | null => {
switch (condition.type) switch (condition.type)
{ {
case 'original-month': case 'original-month':
@@ -2144,8 +2108,7 @@ const exclusiveConditionGroupFor = (
const sameConditionValue = ( const sameConditionValue = (
left: GekanatorQuestion['condition'], left: GekanatorQuestion['condition'],
right: GekanatorQuestion['condition'], right: GekanatorQuestion['condition']): boolean => {
): boolean => {
const leftTitleLength = titleLengthMinimumForCondition (left) const leftTitleLength = titleLengthMinimumForCondition (left)
const rightTitleLength = titleLengthMinimumForCondition (right) const rightTitleLength = titleLengthMinimumForCondition (right)
if (leftTitleLength != null || rightTitleLength != null) if (leftTitleLength != null || rightTitleLength != null)
@@ -2187,8 +2150,7 @@ const sameConditionValue = (
const isMonthCrossMatch = ( const isMonthCrossMatch = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition']): boolean => {
): boolean => {
const candidateMonth = monthForCondition (candidate) const candidateMonth = monthForCondition (candidate)
const previousMonth = monthForCondition (previous) const previousMonth = monthForCondition (previous)
if (candidateMonth == null || previousMonth == null) if (candidateMonth == null || previousMonth == null)
@@ -2204,8 +2166,7 @@ const isMonthCrossMatch = (
const isExclusiveContradiction = ( const isExclusiveContradiction = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition']): boolean => {
): boolean => {
const candidateGroup = exclusiveConditionGroupFor (candidate) const candidateGroup = exclusiveConditionGroupFor (candidate)
const previousGroup = exclusiveConditionGroupFor (previous) const previousGroup = exclusiveConditionGroupFor (previous)
@@ -2242,16 +2203,14 @@ const contradictionPenaltyFor = ({
case 'no': case 'no':
if ( if (
sameConditionValue (question.condition, previous) sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous) || isMonthCrossMatch (question.condition, previous))
)
return sum + 40 return sum + 40
return sum return sum
case 'probably_no': case 'probably_no':
if ( if (
sameConditionValue (question.condition, previous) sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous) || isMonthCrossMatch (question.condition, previous))
)
return sum + 20 return sum + 20
return sum return sum
@@ -2281,8 +2240,7 @@ const chooseQuestion = (
recentFirstQuestionPenaltyById: Map<string, number> recentFirstQuestionPenaltyById: Map<string, number>
userPriorWeights: Map<number, number> userPriorWeights: Map<number, number>
materialIndex: GekanatorQuestionMaterialIndex materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex }, matchIndex: GekanatorMatchIndex }): QuestionSelection | null => {
): QuestionSelection | null => {
const dynamicMatchIndex = new Map<string, Set<number>> () const dynamicMatchIndex = new Map<string, Set<number>> ()
const invertedSignature = (signature: string): string => const invertedSignature = (signature: string): string =>
@@ -2327,8 +2285,7 @@ const chooseQuestion = (
const rank = ( const rank = (
questionsToRank: GekanatorQuestion[], questionsToRank: GekanatorQuestion[],
candidates: { post: Post; score: number }[], candidates: { post: Post; score: number }[],
weightedCandidates: { post: Post; score: number; weight: number }[], weightedCandidates: { post: Post; score: number; weight: number }[]) => {
) => {
const redundant = redundantSignatures (candidates.map (item => item.post)) const redundant = redundantSignatures (candidates.map (item => item.post))
const candidateById = new Map (candidates.map (item => [item.post.id, item.post])) const candidateById = new Map (candidates.map (item => [item.post.id, item.post]))
const candidateIds = candidates.map (item => item.post.id) const candidateIds = candidates.map (item => item.post.id)
@@ -2613,8 +2570,7 @@ const chooseQuestion = (
if ( if (
effectiveRatio < targetEffectiveUserSuggestedQuestionRatio effectiveRatio < targetEffectiveUserSuggestedQuestionRatio
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio && totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
&& effectiveUserSuggestedPool.length > 0 && effectiveUserSuggestedPool.length > 0)
)
{ {
selectedPool = effectiveUserSuggestedPool selectedPool = effectiveUserSuggestedPool
selectedPurpose = 'effective_user_suggested' selectedPurpose = 'effective_user_suggested'
@@ -2622,8 +2578,7 @@ const chooseQuestion = (
else if ( else if (
learningRatio < targetLearningUserSuggestedQuestionRatio learningRatio < targetLearningUserSuggestedQuestionRatio
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio && totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
&& learningUserSuggestedPool.length > 0 && learningUserSuggestedPool.length > 0)
)
{ {
selectedPool = learningUserSuggestedPool selectedPool = learningUserSuggestedPool
selectedPurpose = 'learning_user_suggested' selectedPurpose = 'learning_user_suggested'
@@ -2683,8 +2638,7 @@ const chooseQuestion = (
const winningRunPriorityFor = ( const winningRunPriorityFor = (
expected: GekanatorAnswerValue, expected: GekanatorAnswerValue): number | null => {
): number | null => {
if (expected === 'yes') if (expected === 'yes')
return 0 return 0
if (expected === 'partial') if (expected === 'partial')
@@ -2719,8 +2673,7 @@ const chooseWinningRunQuestion = ({
materialIndex, materialIndex,
acceptedQuestions, acceptedQuestions,
mode: 'confirmation', mode: 'confirmation',
confirmationPostId: targetPost.id confirmationPostId: targetPost.id})
})
.filter (question => { .filter (question => {
if (askedIds.has (question.id)) if (askedIds.has (question.id))
return false return false
@@ -2829,8 +2782,7 @@ const chooseFallbackQuestion = ({
materialIndex, materialIndex,
acceptedQuestions: [], acceptedQuestions: [],
mode: 'confirmation', mode: 'confirmation',
confirmationPostId: post.id confirmationPostId: post.id})))
})))
.slice (0, 32) .slice (0, 32)
const dynamicMatchIndex = new Map<string, Set<number>> () const dynamicMatchIndex = new Map<string, Set<number>> ()
const ranked = mergeQuestions ([ const ranked = mergeQuestions ([
@@ -2905,8 +2857,7 @@ const chooseFallbackQuestion = ({
const shouldEnterGuessPhase = ( const shouldEnterGuessPhase = (
reason: GuessReason | null, reason: GuessReason | null): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
(reason === 'hard_max_questions' (reason === 'hard_max_questions'
|| reason === 'winning_run_finished' || reason === 'winning_run_finished'
|| reason === 'question_count_checkpoint') || reason === 'question_count_checkpoint')
@@ -2914,15 +2865,13 @@ const shouldEnterGuessPhase = (
const isWinningRunActive = ( const isWinningRunActive = (
winningRunTargetId: number | null, winningRunTargetId: number | null,
winningRunStartAnswerCount: number | null, winningRunStartAnswerCount: number | null): boolean =>
): boolean =>
winningRunTargetId != null && winningRunStartAnswerCount != null winningRunTargetId != null && winningRunStartAnswerCount != null
const winningRunQuestionCount = ( const winningRunQuestionCount = (
answers: GekanatorAnswerLog[], answers: GekanatorAnswerLog[],
winningRunStartAnswerCount: number | null, winningRunStartAnswerCount: number | null): number => {
): number => {
if (winningRunStartAnswerCount == null) if (winningRunStartAnswerCount == null)
return 0 return 0
@@ -2962,8 +2911,7 @@ const nextQuestionPlanFor = (
matchIndex: GekanatorMatchIndex matchIndex: GekanatorMatchIndex
lastGuessQuestionCount: number lastGuessQuestionCount: number
winningRunTargetId: number | null winningRunTargetId: number | null
winningRunStartAnswerCount: number | null }, winningRunStartAnswerCount: number | null }): { question: GekanatorQuestion | null
): { question: GekanatorQuestion | null
guess: Post | null guess: Post | null
guessReason: GuessReason | null guessReason: GuessReason | null
questionMode: QuestionMode questionMode: QuestionMode
@@ -3013,8 +2961,7 @@ const nextQuestionPlanFor = (
if ( if (
isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
&& winningRunTargetId === nextWinningRunTargetId && winningRunTargetId === nextWinningRunTargetId
&& winningRunStartAnswerCount != null && winningRunStartAnswerCount != null)
)
return winningRunStartAnswerCount return winningRunStartAnswerCount
return answers.length return answers.length
@@ -3188,8 +3135,7 @@ const mascotStateFor = (
resultWon: boolean | null, resultWon: boolean | null,
eligiblePostCount: number, eligiblePostCount: number,
bestConfidencePercent: number, bestConfidencePercent: number,
winningRunActive: boolean, winningRunActive: boolean): MascotState => {
): MascotState => {
const resultPhase = const resultPhase =
phase === 'end' phase === 'end'
|| phase === 'review' || phase === 'review'
@@ -3210,13 +3156,11 @@ const mascotStateFor = (
if ( if (
winningRunActive winningRunActive
|| eligiblePostCount <= 2 || eligiblePostCount <= 2
|| bestConfidencePercent >= 70 || bestConfidencePercent >= 70)
)
return 'thinking_near' return 'thinking_near'
if ( if (
eligiblePostCount >= 15 eligiblePostCount >= 15
&& bestConfidencePercent < 45 && bestConfidencePercent < 45)
)
return 'thinking_far' return 'thinking_far'
return 'thinking_mid' return 'thinking_mid'
case 'guess': case 'guess':
@@ -3327,8 +3271,7 @@ const GekanatorBackdrop: FC<{
const settingsForMode = useCallback ( const settingsForMode = useCallback (
( (
mode: 'normal' | 'winning_run' | 'guess', mode: 'normal' | 'winning_run' | 'guess'): { columns: number; rows: number; opacity: number } => {
): { columns: number; rows: number; opacity: number } => {
if (mode === 'winning_run' || mode === 'guess') if (mode === 'winning_run' || mode === 'guess')
return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 } return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 }
@@ -3342,8 +3285,7 @@ const GekanatorBackdrop: FC<{
const scaleForMode = useCallback ( const scaleForMode = useCallback (
( (
mode: 'normal' | 'winning_run' | 'guess', mode: 'normal' | 'winning_run' | 'guess',
displayedWinningCount: number, displayedWinningCount: number): number => {
): number => {
if (mode === 'guess') if (mode === 'guess')
return 8 return 8
@@ -3355,8 +3297,7 @@ const GekanatorBackdrop: FC<{
[]) [])
const postsForMode = useCallback (( const postsForMode = useCallback ((
mode: 'normal' | 'winning_run' | 'guess', mode: 'normal' | 'winning_run' | 'guess'): Post[] => {
): Post[] => {
if (mode === 'guess' && displayedGuess) if (mode === 'guess' && displayedGuess)
return [displayedGuess] return [displayedGuess]
if (mode === 'winning_run' && winningRunTargetPost) if (mode === 'winning_run' && winningRunTargetPost)
@@ -3366,8 +3307,7 @@ const GekanatorBackdrop: FC<{
const thumbnailsForMode = useCallback (( const thumbnailsForMode = useCallback ((
mode: 'normal' | 'winning_run' | 'guess', mode: 'normal' | 'winning_run' | 'guess',
count: number, count: number): string[] => {
): string[] => {
const modePosts = postsForMode (mode) const modePosts = postsForMode (mode)
if (modePosts.length === 0) if (modePosts.length === 0)
return [] return []
@@ -3734,8 +3674,7 @@ const GekanatorBackdrop: FC<{
const expectedAnswerFor = ( const expectedAnswerFor = (
question: GekanatorQuestion | undefined, question: GekanatorQuestion | undefined,
correctPost: Post | null, correctPost: Post | null): GekanatorAnswerValue | null =>
): GekanatorAnswerValue | null =>
expectedAnswerForQuestion (question, correctPost) expectedAnswerForQuestion (question, correctPost)
@@ -4169,8 +4108,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (true) setSaved (true)
setSavedGameId (data.id) setSavedGameId (data.id)
setLearnedExampleCount (data.learnedExampleCount) setLearnedExampleCount (data.learnedExampleCount)
setResultWon (variables.guessedPostId === variables.correctPostId) setResultWon (variables.guessedPostId === variables.correctPostId)}})
}})
const questionSuggestionMutation = useMutation ({ const questionSuggestionMutation = useMutation ({
mutationFn: saveGekanatorQuestionSuggestion, mutationFn: saveGekanatorQuestionSuggestion,
onSuccess: async data => { onSuccess: async data => {
@@ -4180,15 +4118,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('') setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes') setQuestionSuggestionAnswer ('yes')}})
}})
const extraQuestionAnswersMutation = useMutation ({ const extraQuestionAnswersMutation = useMutation ({
mutationFn: saveGekanatorExtraQuestionAnswers, mutationFn: saveGekanatorExtraQuestionAnswers,
onSuccess: async () => { onSuccess: async () => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
setExtraQuestionState ('saved') setExtraQuestionState ('saved')
setPhase ('end') setPhase ('end')}})
}})
const resetExtraQuestionState = () => { const resetExtraQuestionState = () => {
const next = resettableExtraQuestionState () const next = resettableExtraQuestionState ()
@@ -4330,8 +4266,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if ( if (
!(allowPreQuestionRecovery) !(allowPreQuestionRecovery)
|| recoveredEligiblePosts.length === 0 || recoveredEligiblePosts.length === 0
|| recoveredEligiblePosts.length === 1 || recoveredEligiblePosts.length === 1)
)
return false return false
const nextQuestion = chooseQuestion ({ const nextQuestion = chooseQuestion ({
@@ -4493,8 +4428,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if ( if (
!(nextPlan.question) !(nextPlan.question)
&& !(shouldEnterGuessPhase (nextPlan.guessReason)) && !(shouldEnterGuessPhase (nextPlan.guessReason))
&& recovered.eligiblePosts.length !== 1 && recovered.eligiblePosts.length !== 1)
)
{ {
const recoveredForQuestion = recoverQuestionState ({ const recoveredForQuestion = recoverQuestionState ({
nextAnswers, nextAnswers,
@@ -4602,8 +4536,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
!(canPersistGame) !(canPersistGame)
|| reviewGuessedPostId == null || reviewGuessedPostId == null
|| reviewCorrectPostId == null || reviewCorrectPostId == null
|| saveMutation.isPending || saveMutation.isPending)
)
return return
if (savedGameId != null) if (savedGameId != null)
@@ -4666,8 +4599,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
!(canPersistGame) !(canPersistGame)
|| savedGameId == null || savedGameId == null
|| extraQuestionAnswersMutation.isPending || extraQuestionAnswersMutation.isPending
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])) || extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])))
)
return return
extraQuestionAnswersMutation.mutate ({ extraQuestionAnswersMutation.mutate ({
@@ -4870,8 +4802,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const answerExtraQuestion = ( const answerExtraQuestion = (
questionId: number, questionId: number,
value: GekanatorAnswerValue, value: GekanatorAnswerValue) => {
) => {
setExtraQuestionAnswers ({ setExtraQuestionAnswers ({
...extraQuestionAnswers, ...extraQuestionAnswers,
[String (questionId)]: value }) [String (questionId)]: value })
@@ -4909,8 +4840,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|| isLoading || isLoading
|| acceptedQuestionsLoading || acceptedQuestionsLoading
|| shouldEnterGuessPhase (questionPlan.guessReason) || shouldEnterGuessPhase (questionPlan.guessReason)
|| eligiblePosts.length === 1 || eligiblePosts.length === 1)
)
return return
const recovered = recoverQuestionState ({ const recovered = recoverQuestionState ({
@@ -4926,8 +4856,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if ( if (
recovered.recoveryStepCount === recoveryStepCount recovered.recoveryStepCount === recoveryStepCount
&& recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size && recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size
&& recovered.softenedQuestionIds.size === softenedQuestionIds.size && recovered.softenedQuestionIds.size === softenedQuestionIds.size)
)
return return
setSoftenedQuestionIds (recovered.softenedQuestionIds) setSoftenedQuestionIds (recovered.softenedQuestionIds)
@@ -4954,15 +4883,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if ( if (
phase !== 'question' phase !== 'question'
|| isLoading || isLoading
|| acceptedQuestionsLoading || acceptedQuestionsLoading)
)
return return
if ( if (
currentQuestion currentQuestion
|| !(questionPlan.guess) || !(questionPlan.guess)
|| !(shouldEnterGuessPhase (questionPlan.guessReason)) || !(shouldEnterGuessPhase (questionPlan.guessReason)))
)
return return
setWinningRunTargetId (questionPlan.winningRunTargetId) setWinningRunTargetId (questionPlan.winningRunTargetId)
+2 -4
ファイルの表示
@@ -145,8 +145,7 @@ const DeerjikistDetailPage: FC = () => {
return rtn return rtn
})}/>)} })}/>)}
</FormField> </FormField>
</fieldset> </fieldset>))}
))}
<div className="py-3"> <div className="py-3">
<button <button
@@ -169,8 +168,7 @@ const DeerjikistDetailPage: FC = () => {
</button> </button>
</div> </div>
</form> </form>
</div> </div>)}
)}
</MainArea>) </MainArea>)
} }
+4 -1
ファイルの表示
@@ -8,6 +8,7 @@ import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (), apiGet: vi.fn (),
apiPatch: vi.fn (),
apiPut: vi.fn (), apiPut: vi.fn (),
})) }))
@@ -26,7 +27,7 @@ vi.mock ('@/components/ui/use-toast', () => toastApi)
const renderPage = () => const renderPage = () =>
renderWithProviders ( renderWithProviders (
<Routes> <Routes>
<Route path="/materials/:id" element={<MaterialDetailPage/>}/> <Route path="/materials/:id" element={<MaterialDetailPage user={null}/>}/>
</Routes>, </Routes>,
{ route: '/materials/8' }, { route: '/materials/8' },
) )
@@ -73,6 +74,7 @@ describe ('MaterialDetailPage', () => {
const textboxes = screen.getAllByRole ('textbox') const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'new' } }) fireEvent.change (textboxes[0], { target: { value: 'new' } })
fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } }) fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } })
fireEvent.change (textboxes[2], { target: { value: '素材/new.png' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' })) fireEvent.click (screen.getByRole ('button', { name: '更新' }))
await waitFor (() => { await waitFor (() => {
@@ -81,6 +83,7 @@ describe ('MaterialDetailPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('new') expect (formData.get ('tag')).toBe ('new')
expect (formData.get ('url')).toBe ('https://example.com/ref') expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' }) expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' })
}) })
}) })
+153 -84
ファイルの表示
@@ -1,3 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
@@ -13,107 +14,149 @@ import MainArea from '@/components/layout/MainArea'
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 { apiGet, apiPut } from '@/lib/api' import {
suppressMaterialFile,
fetchMaterial,
updateMaterial,
} from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react' import type { FC } from 'react'
import type { Material, Tag } from '@/types' import type { User } from '@/types'
type MaterialWithTag = Material & { tag: Tag } type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
type MaterialFormField = 'tag' | 'file' | 'url'
const MaterialDetailPage: FC = () => { const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
const { id } = useParams () const { id } = useParams ()
const qc = useQueryClient ()
const [exportPath, setExportPath] = useState ('')
const [file, setFile] = useState<File | null> (null) const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('') const [filePreview, setFilePreview] = useState ('')
const [loading, setLoading] = useState (false)
const [material, setMaterial] = useState<MaterialWithTag | null> (null)
const [sending, setSending] = useState (false)
const [tag, setTag] = useState ('') const [tag, setTag] = useState ('')
const [url, setURL] = useState ('') const [url, setURL] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> () useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => { const { data: material, isError, isLoading } = useQuery ({
clearValidationErrors () queryKey: materialsKeys.show (id ?? ''),
queryFn: () => fetchMaterial (id ?? ''),
const formData = new FormData enabled: id != null})
if (tag.trim ()) const materialTitle = material
formData.append ('tag', tag) ? material.tag?.name ?? `素材 #${ material.id }`
if (file) : ''
formData.append ('file', file)
if (url.trim ())
formData.append ('url', url)
try
{
setSending (true)
const data = await apiPut<Material> (`/materials/${ id }`, formData)
setMaterial (data)
toast ({ title: '更新成功!' })
}
catch (e)
{
applyValidationError (e)
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
}
useEffect (() => { useEffect (() => {
if (!(id)) if (!(material))
return return
void (async () => { setTag (material.tag?.name ?? '')
try setURL (material.url ?? '')
setExportPath (material.exportPaths.legacyDrive ?? '')
if (material.file && material.contentType)
{ {
setLoading (true) setFilePreview (material.file)
const data = await apiGet<MaterialWithTag> (`/materials/${ id }`) setFile (null)
setMaterial (data)
setTag (data.tag.name)
if (data.file && data.contentType)
{
setFilePreview (data.file)
setFile (new File ([await (await fetch (data.file)).blob ()],
data.file,
{ type: data.contentType }))
}
setURL (data.url ?? '')
} }
finally }, [material])
{
setLoading (false) const invalidateMaterialQueries = async () => {
} await qc.invalidateQueries ({ queryKey: materialsKeys.root })
}) () }
}, [id])
const updateMutation = useMutation ({
mutationFn: async () => {
const formData = new FormData
if (tag.trim ())
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url.trim ())
formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
return await updateMaterial (id ?? '', formData)
},
onSuccess: async data => {
qc.setQueryData (materialsKeys.show (id ?? ''), data)
await invalidateMaterialQueries ()
toast ({ title: '更新成功!' })
},
onError: error => {
applyValidationError (error)
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
}})
const suppressMutation = useMutation ({
mutationFn: async (reason: string) =>
await suppressMaterialFile (id ?? '', { reason }),
onSuccess: async data => {
qc.setQueryData (materialsKeys.show (id ?? ''), data)
setFile (null)
setFilePreview ('')
await invalidateMaterialQueries ()
toast ({ title: '抑止しました' })
},
onError: () => {
toast ({ title: '抑止に失敗しました' })
}})
const handleSubmit = () => {
clearValidationErrors ()
updateMutation.mutate ()
}
const handleSuppress = () => {
const reason = window.prompt ('抑止理由を入力してください。')
if (reason == null || reason.trim () === '')
return
if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。'))
return
suppressMutation.mutate (reason)
}
return ( return (
<MainArea> <MainArea>
{material && ( {material && (
<Helmet> <Helmet>
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title> <title>{`${ materialTitle } 素材照会 | ${ SITE_TITLE }`}</title>
</Helmet>)} </Helmet>)}
{loading ? 'Loading...' : (material && ( {isLoading ? 'Loading...' : isError ? (
<p className="text-red-600 dark:text-red-300">
</p>) : material == null ? (
<p className="text-stone-700 dark:text-stone-300">
</p>) : (
<> <>
<PageTitle> <PageTitle>
<TagLink {material.tag
tag={material.tag} ? (
withWiki={false} <TagLink
withCount={false}/> tag={material.tag}
withWiki={false}
withCount={false}/>)
: materialTitle}
</PageTitle> </PageTitle>
{(material.file && material.contentType) && ( {material.fileSuppressedAt && (
<div className="mb-4 rounded border border-red-300 bg-red-50 p-3
text-red-900 dark:border-red-800 dark:bg-red-950
dark:text-red-100">
<span></span>
{material.fileSuppressionReason && (
<span> : {material.fileSuppressionReason}</span>)}
</div>)}
{(!material.fileSuppressedAt && material.file && material.contentType) && (
(/image\/.*/.test (material.contentType) && ( (/image\/.*/.test (material.contentType) && (
<img src={material.file} alt={material.tag.name || undefined}/>)) <img src={material.file} alt={material.tag?.name || undefined}/>))
|| (/video\/.*/.test (material.contentType) && ( || (/video\/.*/.test (material.contentType) && (
<video src={material.file} controls/>)) <video src={material.file} controls/>))
|| (/audio\/.*/.test (material.contentType) && ( || (/audio\/.*/.test (material.contentType) && (
@@ -121,16 +164,21 @@ const MaterialDetailPage: FC = () => {
<TabGroup> <TabGroup>
<Tab name="Wiki"> <Tab name="Wiki">
<WikiBody {material.tag
title={material.tag.name} ? (
body={material.wikiPageBody ?? undefined}/> <WikiBody
title={material.tag.name}
body={material.wikiPageBody ?? undefined}/>)
: (
<p className="text-stone-700 dark:text-stone-300">
</p>)}
</Tab> </Tab>
<Tab name="編輯"> <Tab name="編輯">
<div className="max-w-wl pt-2 space-y-4"> <div className="max-w-wl space-y-4 pt-2">
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
{/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}> <FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<TagInput <TagInput
@@ -140,7 +188,6 @@ const MaterialDetailPage: FC = () => {
setValue={setTag}/>)} setValue={setTag}/>)}
</FormField> </FormField>
{/* ファイル */}
<FormField label="ファイル" messages={fieldErrors.file}> <FormField label="ファイル" messages={fieldErrors.file}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
@@ -150,9 +197,10 @@ const MaterialDetailPage: FC = () => {
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
onChange={e => { onChange={e => {
const f = e.target.files?.[0] const nextFile = e.target.files?.[0]
setFile (f ?? null) setFile (nextFile ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '') setFilePreview (
nextFile ? URL.createObjectURL (nextFile) : '')
}}/> }}/>
{(file && filePreview) && ( {(file && filePreview) && (
(/image\/.*/.test (file.type) && ( (/image\/.*/.test (file.type) && (
@@ -177,7 +225,6 @@ const MaterialDetailPage: FC = () => {
</>)} </>)}
</FormField> </FormField>
{/* 参考 URL */}
<FormField label="参考 URL" messages={fieldErrors.url}> <FormField label="参考 URL" messages={fieldErrors.url}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input <input
@@ -189,17 +236,39 @@ const MaterialDetailPage: FC = () => {
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
{/* 送信 */} <FormField label="ZIP 出力パス" messages={fieldErrors.exportPaths}>
<Button {({ describedBy, invalid }) => (
onClick={handleSubmit} <input
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" type="text"
disabled={sending}> value={exportPath}
onChange={e => setExportPath (e.target.value)}
</Button> placeholder="伊地知ニジカ/表情/泣き.png"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleSubmit}
className="rounded bg-blue-600 px-4 py-2 text-white
disabled:bg-gray-400"
disabled={updateMutation.isPending}>
</Button>
{user?.role === 'admin' && !material.fileSuppressedAt && (
<Button
type="button"
variant="destructive"
onClick={handleSuppress}
disabled={suppressMutation.isPending}>
</Button>)}
</div>
</div> </div>
</Tab> </Tab>
</TabGroup> </TabGroup>
</>))} </>)}
</MainArea>) </MainArea>)
} }
+439 -126
ファイルの表示
@@ -1,75 +1,280 @@
import { Fragment, useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, 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 nikumaru from '@/assets/fonts/nikumaru.otf' import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import SectionTitle from '@/components/common/SectionTitle' import Pagination from '@/components/common/Pagination'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { API_BASE_URL, SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api' import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import type { FC } from 'react' import type { FC, FormEvent } from 'react'
import type { Material, Tag } from '@/types' import type {
FetchMaterialsParams,
Material,
MaterialIndexDirection,
MaterialIndexMediaKind,
MaterialIndexSort,
MaterialIndexSuppression,
MaterialIndexTagState,
MaterialIndexView } from '@/types'
type TagWithMaterial = Omit<Tag, 'children'> & { const MEDIA_KIND_LABELS: Record<Material['mediaKind'], string> = {
children: TagWithMaterial[] image: '画像',
material: Material | null } video: '動画',
audio: '音声',
file_other: 'その他ファイル',
url_only: 'URL のみ',
suppressed: '抑止済み'}
const MEDIA_FILTER_LABELS: Record<MaterialIndexMediaKind, string> = {
all: 'すべて',
image: '画像',
video: '動画',
audio: '音声',
file_other: 'その他ファイル',
url_only: 'URL のみ'}
const SORT_LABELS: Record<MaterialIndexSort, string> = {
created_at: '作成日時',
updated_at: '更新日時',
tag_name: 'タグ名',
media_kind: '種類',
file_byte_size: 'ファイルサイズ',
version_no: 'バージョン',
id: 'ID'}
const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => { const setIf = (qs: URLSearchParams, key: string, value: string | null) => {
if (!(tag.material)) const next = value?.trim ()
return if (next)
qs.set (key, next)
return (
<PrefetchLink
to={`/materials/${ tag.material.id }`}
className="block w-40 h-40">
<div
className="w-full h-full overflow-hidden rounded-xl shadow
text-center content-center text-4xl"
style={{ fontFamily: 'Nikumaru' }}>
{(tag.material.contentType && /image\/.*/.test (tag.material.contentType))
? <img src={tag.material.file || undefined}/>
: <span></span>}
</div>
</PrefetchLink>)
} }
const MaterialListPage: FC = () => { const parseOption = <T extends string> (
const [loading, setLoading] = useState (false) value: string | null,
const [tag, setTag] = useState<TagWithMaterial | null> (null) allowed: readonly T[],
fallback: T): T => allowed.includes (value as T) ? value as T : fallback
const fileSizeText = (bytes: number | null): string => {
if (bytes == null)
return ''
if (bytes < 1024)
return `${ bytes } B`
if (bytes < 1024 * 1024)
return `${ (bytes / 1024).toFixed (1) } KB`
return `${ (bytes / 1024 / 1024).toFixed (1) } MB`
}
const materialTitle = (material: Material): string =>
material.tag?.name ?? `素材 #${ material.id }`
const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
<div
className={`flex aspect-square h-[180px] w-[180px] items-center justify-center
overflow-hidden rounded-lg border text-center shadow-sm ${
material.fileSuppressedAt
? [
'border-red-300 bg-red-50 text-red-900 dark:border-red-800',
'dark:bg-red-950 dark:text-red-100'].join (' ')
: [
'border-stone-200 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
{material.thumbnail
? <img src={material.thumbnail} alt="" className="h-full w-full object-contain"/>
: (
<span
className="px-2 text-2xl leading-tight"
style={{ fontFamily: material.thumbnailFallbackKind === 'tag_name'
? 'Nikumaru'
: undefined }}>
{material.thumbnailFallbackText}
</span>)}
</div>)
const MaterialCard: FC<{ material: Material }> = ({ material }) => (
<article className="w-[180px] justify-self-center">
<PrefetchLink to={`/materials/${ material.id }`} className="block">
<MaterialThumb material={material}/>
<div className="mt-2 w-[180px]">
<p className="truncate text-sm font-medium text-stone-900 dark:text-stone-100">
{materialTitle (material)}
</p>
<p className="truncate text-xs text-stone-600 dark:text-stone-300">
{MEDIA_KIND_LABELS[material.mediaKind]} / {dateString (material.createdAt)}
</p>
</div>
</PrefetchLink>
</article>)
const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
<article
className={`rounded-lg border p-3 shadow-sm ${
material.fileSuppressedAt
? [
'border-red-200 bg-red-50 text-red-900 dark:border-red-900',
'dark:bg-red-950 dark:text-red-100'].join (' ')
: [
'border-stone-200 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ')}`}>
<div className="flex gap-3">
<MaterialThumb material={material}/>
<div className="min-w-0 flex-1 space-y-2">
<div>
<PrefetchLink
to={`/materials/${ material.id }`}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300">
{materialTitle (material)}
</PrefetchLink>
{material.fileSuppressedAt && (
<p className="mt-1 text-sm text-red-700 dark:text-red-200"></p>)}
</div>
<dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300">
<div>
<dt className="inline">: </dt>
<dd className="inline">{MEDIA_KIND_LABELS[material.mediaKind]}</dd>
</div>
{material.fileByteSize != null && (
<div>
<dt className="inline">: </dt>
<dd className="inline">{fileSizeText (material.fileByteSize)}</dd>
</div>)}
{material.url && (
<div>
<dt className="inline">URL: </dt>
<dd className="inline break-all">{material.url}</dd>
</div>)}
<div>
<dt className="inline">: </dt>
<dd className="inline">{dateString (material.createdAt)}</dd>
</div>
</dl>
</div>
</div>
</article>)
const MaterialListPage: FC = () => {
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const navigate = useNavigate ()
const tagQuery = query.get ('tag') ?? '' const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const materialFilter = parseMaterialFilter (query.get ('material_filter'), 'present')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const qQuery = query.get ('q') ?? query.get ('tag') ?? ''
const tagState = query.get ('unclassified') === '1'
? 'untagged'
: parseOption<MaterialIndexTagState> (
query.get ('tag_state'),
['all', 'tagged', 'untagged'],
'all')
const mediaKind = parseOption<MaterialIndexMediaKind> (
query.get ('media_kind'),
['all', 'image', 'video', 'audio', 'file_other', 'url_only'],
'all')
const suppression = parseOption<MaterialIndexSuppression> (
query.get ('suppression'),
['active', 'suppressed', 'all'],
'active')
const sort = parseOption<MaterialIndexSort> (
query.get ('sort'),
['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
'version_no', 'id'],
'created_at')
const direction = parseOption<MaterialIndexDirection> (
query.get ('direction'),
['asc', 'desc'],
'desc')
const view = parseOption<MaterialIndexView> (query.get ('view'), ['card', 'list'], 'card')
const createdFromQuery = query.get ('created_from') ?? ''
const createdToQuery = query.get ('created_to') ?? ''
const updatedFromQuery = query.get ('updated_from') ?? ''
const updatedToQuery = query.get ('updated_to') ?? ''
const [q, setQ] = useState ('')
const [tagStateInput, setTagStateInput] = useState<MaterialIndexTagState> ('all')
const [mediaKindInput, setMediaKindInput] = useState<MaterialIndexMediaKind> ('all')
const [suppressionInput, setSuppressionInput] =
useState<MaterialIndexSuppression> ('active')
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
const keys: FetchMaterialsParams = {
q: qQuery,
tagState,
mediaKind,
suppression,
createdFrom: createdFromQuery,
createdTo: createdToQuery,
updatedFrom: updatedFromQuery,
updatedTo: updatedToQuery,
sort,
direction,
view,
page,
limit}
const { data, isLoading, isError } = useQuery ({
queryKey: materialsKeys.index (keys),
queryFn: () => fetchMaterials (keys)})
const materials = data?.materials ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => { useEffect (() => {
if (!(tagQuery)) setQ (qQuery)
{ setTagStateInput (tagState)
setTag (null) setMediaKindInput (mediaKind)
return setSuppressionInput (suppression)
} setCreatedFrom (createdFromQuery)
setCreatedTo (createdToQuery)
setUpdatedFrom (updatedFromQuery)
setUpdatedTo (updatedToQuery)
}, [createdFromQuery, createdToQuery, mediaKind, qQuery, suppression, tagState,
updatedFromQuery, updatedToQuery])
void (async () => { const search = (e: FormEvent) => {
try e.preventDefault ()
{
setLoading (true) const qs = new URLSearchParams ()
setTag ( setIf (qs, 'q', q)
await apiGet<TagWithMaterial> ( qs.set ('tag_state', tagStateInput)
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`)) qs.set ('media_kind', mediaKindInput)
} qs.set ('suppression', suppressionInput)
finally setIf (qs, 'created_from', createdFrom)
{ setIf (qs, 'created_to', createdTo)
setLoading (false) setIf (qs, 'updated_from', updatedFrom)
} setIf (qs, 'updated_to', updatedTo)
}) () qs.set ('sort', sort)
}, [location.search, tagQuery]) qs.set ('direction', direction)
qs.set ('view', view)
qs.set ('page', '1')
qs.set ('limit', String (limit))
qs.set ('material_filter', materialFilter)
navigate (`/materials?${ qs.toString () }`)
}
const updateQuery = (changes: Record<string, string>) => {
const qs = new URLSearchParams (location.search)
Object.entries (changes).forEach (([key, value]) => qs.set (key, value))
navigate (`/materials?${ qs.toString () }`)
}
return ( return (
<MainArea> <MainArea>
@@ -82,86 +287,194 @@ const MaterialListPage: FC = () => {
src: url(${ nikumaru }) format('opentype'); src: url(${ nikumaru }) format('opentype');
}`} }`}
</style> </style>
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title> <title>{`素材一覧 | ${ SITE_TITLE }`}</title>
</Helmet> </Helmet>
{loading ? 'Loading...' : ( <div className="space-y-5">
tag <div className="flex flex-wrap items-center justify-between gap-3">
? ( <PageTitle></PageTitle>
<> <div className="flex flex-wrap gap-2">
<PageTitle> <PrefetchLink
<TagLink to="/materials/new"
tag={tag} className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm
withWiki={false} text-stone-900 hover:bg-stone-100 dark:border-stone-700
withCount={false} dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
to={tag.material
? `/materials/${ tag.material.id }` </PrefetchLink>
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/> <a
</PageTitle> href={`${ API_BASE_URL }/materials/download.zip?profile=legacy_drive`}
{(!(tag.material) && tag.category !== 'meme') && ( target="_blank"
<div className="-mt-2"> rel="noopener noreferrer"
<PrefetchLink className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}> text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
</PrefetchLink> ZIP
</div>)} </a>
</div>
</div>
<MaterialCard tag={tag}/> {tagState === 'untagged' && (
<PrefetchLink
to={`/materials?material_filter=${ materialFilter }`}
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
</PrefetchLink>)}
<div className="ml-2"> <form
{tag.children.map (c2 => ( onSubmit={search}
<Fragment key={c2.id}> className="max-w-3xl rounded-lg border border-stone-200 bg-white p-4
<SectionTitle> text-stone-900 dark:border-stone-700 dark:bg-stone-900
<TagLink dark:text-stone-100">
tag={c2} <div className="space-y-3">
withWiki={false} <FormField label="検索">
withCount={false} {({ invalid }) => (
to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/> <input
</SectionTitle> type="search"
{(!(c2.material) && c2.category !== 'meme') && ( value={q}
<div className="-mt-4"> onChange={e => setQ (e.target.value)}
<PrefetchLink placeholder="タグ名 / URL / ファイル名"
to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}> className={inputClass (invalid)}/>)}
</FormField>
</PrefetchLink>
</div>)}
<MaterialCard tag={c2}/> <FormField label="タグ状態">
{({ invalid }) => (
<select
value={tagStateInput}
onChange={e => setTagStateInput (
e.target.value as MaterialIndexTagState)}
className={inputClass (invalid)}>
<option value="all"></option>
<option value="tagged"></option>
<option value="untagged"></option>
</select>)}
</FormField>
<div className="ml-2"> <FormField label="メディア">
{c2.children.map (c3 => ( {({ invalid }) => (
<Fragment key={c3.id}> <select
<SubsectionTitle> value={mediaKindInput}
<TagLink onChange={e => setMediaKindInput (
tag={c3} e.target.value as MaterialIndexMediaKind)}
withWiki={false} className={inputClass (invalid)}>
withCount={false} {Object.entries (MEDIA_FILTER_LABELS).map (([value, label]) => (
to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/> <option key={value} value={value}>
</SubsectionTitle> {label}
{(!(c3.material) && c3.category !== 'meme') && ( </option>))}
<div className="-mt-2"> </select>)}
<PrefetchLink </FormField>
to={`/materials/new?tag=${
encodeURIComponent (c3.name) }`}>
</PrefetchLink>
</div>)}
<MaterialCard tag={c3}/> <FormField label="抑止状態">
</Fragment>))} {({ invalid }) => (
</div> <select
</Fragment>))} value={suppressionInput}
</div> onChange={e => setSuppressionInput (
</>) e.target.value as MaterialIndexSuppression)}
: ( className={inputClass (invalid)}>
<> <option value="active"></option>
<p></p> <option value="suppressed"></option>
<p></p> <option value="all"></option>
<ul> </select>)}
<li><PrefetchLink to="/materials/new"></PrefetchLink></li> </FormField>
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
</ul> <FormField label="作成日時">
</>))} {() => (
<div className="flex flex-wrap items-center gap-2">
<DateTimeField value={createdFrom ?? undefined} onChange={setCreatedFrom}/>
<span></span>
<DateTimeField value={createdTo ?? undefined} onChange={setCreatedTo}/>
</div>)}
</FormField>
<FormField label="更新日時">
{() => (
<div className="flex flex-wrap items-center gap-2">
<DateTimeField value={updatedFrom ?? undefined} onChange={setUpdatedFrom}/>
<span></span>
<DateTimeField value={updatedTo ?? undefined} onChange={setUpdatedTo}/>
</div>)}
</FormField>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="submit"
className="rounded bg-sky-600 px-4 py-2 text-white hover:bg-sky-700
dark:bg-sky-500 dark:text-stone-950 dark:hover:bg-sky-400">
</button>
</div>
</form>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => updateQuery ({ view: 'card' })}
className={`rounded-full border px-4 py-2 text-sm ${
view === 'card'
? [
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
</button>
<button
type="button"
onClick={() => updateQuery ({ view: 'list' })}
className={`rounded-full border px-4 py-2 text-sm ${
view === 'list'
? [
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
<select
value={sort}
onChange={e => updateQuery ({ sort: e.target.value, page: '1' })}
className={inputClass (false, 'w-auto')}>
{Object.entries (SORT_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
</option>))}
</select>
<select
value={direction}
onChange={e => updateQuery ({ direction: e.target.value, page: '1' })}
className={inputClass (false, 'w-auto')}>
<option value="desc"></option>
<option value="asc"></option>
</select>
</div>
</div>
{isLoading && <p>Loading...</p>}
{isError && (
<p className="text-red-600 dark:text-red-300"></p>)}
{(!isLoading && !isError && materials.length === 0) && (
<p></p>)}
{materials.length > 0 && (
view === 'card'
? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(196px,1fr))]
justify-items-center gap-4">
{materials.map (material => (
<MaterialCard key={material.id} material={material}/>))}
</div>)
: (
<div className="space-y-3">
{materials.map (material => (
<MaterialListItem key={material.id} material={material}/>))}
</div>))}
<Pagination page={page} totalPages={totalPages}/>
</div>
</MainArea>) </MainArea>)
} }
+46 -30
ファイルの表示
@@ -1,3 +1,4 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
@@ -11,16 +12,18 @@ import MainArea from '@/components/layout/MainArea'
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 { apiPost } from '@/lib/api' import { createMaterial } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react' import type { FC } from 'react'
type MaterialFormField = 'tag' | 'file' | 'url' type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialNewPage: FC = () => { const MaterialNewPage: FC = () => {
const qc = useQueryClient ()
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? '' const tagQuery = query.get ('tag') ?? ''
@@ -29,39 +32,38 @@ const MaterialNewPage: FC = () => {
const [file, setFile] = useState<File | null> (null) const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('') const [filePreview, setFilePreview] = useState ('')
const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery) const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('') const [url, setURL] = useState ('')
const [exportPath, setExportPath] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> () useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => { const createMutation = useMutation ({
mutationFn: async () => {
const formData = new FormData
if (tag)
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url)
formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
return await createMaterial (formData)
},
onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
},
onError: error => {
applyValidationError (error)
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
}})
const handleSubmit = () => {
clearValidationErrors () clearValidationErrors ()
createMutation.mutate ()
const formData = new FormData
if (tag)
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url)
formData.append ('url', url)
try
{
setSending (true)
await apiPost ('/materials', formData)
toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
}
catch (e)
{
applyValidationError (e)
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
} }
return ( return (
@@ -133,11 +135,25 @@ const MaterialNewPage: FC = () => {
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
<FormField
label="ZIP 出力パス"
messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => (
<input
type="text"
value={exportPath}
onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 送信 */} {/* 送信 */}
<Button <Button
onClick={handleSubmit} 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"
disabled={sending}> disabled={createMutation.isPending}>
</Button> </Button>
</Form> </Form>
+2 -4
ファイルの表示
@@ -62,8 +62,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
toast ({ title: '失敗……', description: '通信に失敗しました……' }) toast ({ title: '失敗……', description: '通信に失敗しました……' })
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: postsKeys.root })} })
} })
useEffect (() => { useEffect (() => {
if (!(errorFlg)) if (!(errorFlg))
@@ -114,8 +113,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
<PostList posts={[{ ...post, childPosts: [{ } as Post] }, <PostList posts={[{ ...post, childPosts: [{ } as Post] },
...post.childPosts!.map (p => ({ ...post.childPosts!.map (p => ({
...p, parentPosts: [{ } as Post] }))]}/> ...p, parentPosts: [{ } as Post] }))]}/>
</div> </div>)}
)}
{(post.parentPosts ?? []).map (pp => { {(post.parentPosts ?? []).map (pp => {
const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`] const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`]
if (!(siblings)) if (!(siblings))
+14 -20
ファイルの表示
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, LoaderCircle, Pencil, X } from 'lucide-react' import { Check, LoaderCircle, Pencil, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { Fragment, 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 { useLocation, useNavigate } from 'react-router-dom'
@@ -87,8 +87,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
const defaultDirection = { const defaultDirection = {
name: 'asc', name: 'asc',
created_at: 'desc', created_at: 'desc',
updated_at: 'desc', updated_at: 'desc'} as const
} as const
const beginEdit = async (tag: NicoTag) => { const beginEdit = async (tag: NicoTag) => {
const editingTag = nicoTags.find (tag => tag.id === editingId) const editingTag = nicoTags.find (tag => tag.id === editingId)
@@ -99,15 +98,13 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
&& !(await dialogue.confirm ({ && !(await dialogue.confirm ({
title: '編集中の内容を破棄しますか?', title: '編集中の内容を破棄しますか?',
confirmText: '破棄', confirmText: '破棄',
variant: 'danger', variant: 'danger'})))
})))
return return
setEditingId (tag.id) setEditingId (tag.id)
setRawTags (rawTags => ({ setRawTags (rawTags => ({
...rawTags, ...rawTags,
[tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' '), [tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' ')}))
}))
setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] })) setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] }))
} }
@@ -115,8 +112,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setEditingId (null) setEditingId (null)
setRawTags (rawTags => ({ setRawTags (rawTags => ({
...rawTags, ...rawTags,
[tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' '), [tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' ')}))
}))
setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] })) setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] }))
} }
@@ -141,8 +137,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
...errors, ...errors,
[id]: validationError?.fieldErrors.tags [id]: validationError?.fieldErrors.tags
?? validationError?.baseErrors ?? validationError?.baseErrors
?? ['更新できませんでした.'], ?? ['更新できませんでした.']}))
}))
toast ({ title: '更新失敗', description: '入力内容を確認してください.' }) toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
} }
finally finally
@@ -166,8 +161,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setRawTags (Object.fromEntries (data.tags.map (tag => [ setRawTags (Object.fromEntries (data.tags.map (tag => [
tag.id, tag.id,
tag.linkedTags.map (linkedTag => linkedTag.name).join (' '), tag.linkedTags.map (linkedTag => linkedTag.name).join (' ')])))
])))
}, [data]) }, [data])
useEffect (() => { useEffect (() => {
@@ -282,7 +276,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
{nicoTags.map (tag => { {nicoTags.map (tag => {
const isEditing = editingId === tag.id const isEditing = editingId === tag.id
return [ return (
<Fragment key={tag.id}>
<tr <tr
key={tag.id} key={tag.id}
className={cn ( className={cn (
@@ -321,8 +316,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
</button>)} </button>)}
</td>)} </td>)}
</tr>, </tr>
isEditing && ( {isEditing && (
<tr key={`${ tag.id }-edit`} <tr key={`${ tag.id }-edit`}
className="border-b border-rose-200 bg-rose-50 dark:border-rose-900 className="border-b border-rose-200 bg-rose-50 dark:border-rose-900
dark:bg-rose-950/30"> dark:bg-rose-950/30">
@@ -340,8 +335,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
placeholder="タグ名を空白または改行で区切って入力" placeholder="タグ名を空白または改行で区切って入力"
onChange={e => setRawTags (rawTags => ({ onChange={e => setRawTags (rawTags => ({
...rawTags, ...rawTags,
[tag.id]: e.target.value, [tag.id]: e.target.value}))}/>
}))}/>
<FieldError messages={errorsByTagId[tag.id]}/> <FieldError messages={errorsByTagId[tag.id]}/>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
@@ -368,8 +362,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
</div> </div>
</div> </div>
</td> </td>
</tr>), </tr>)}
] </Fragment>)
})} })}
</tbody> </tbody>
</table> </table>
+2 -4
ファイルの表示
@@ -69,8 +69,7 @@ const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
const commentBox = ( const commentBox = (
comment: TheatreComment, comment: TheatreComment,
programme: TheatreProgramme | null = null, programme: TheatreProgramme | null = null): ReactNode[] =>
): ReactNode[] =>
[( [(
<div key={`${ comment.no }-content`} className="w-full"> <div key={`${ comment.no }-content`} className="w-full">
{comment.deleted {comment.deleted
@@ -120,8 +119,7 @@ const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
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 grouped = tagsByCategory (tags)
if (flow === 'horizontal') if (flow === 'horizontal')
+1 -2
ファイルの表示
@@ -15,5 +15,4 @@ export const useSharedTransitionStore = create<SharedTransitionState> (set => ({
set (state => { set (state => {
const next = { ...state.byLocationKey } const next = { ...state.byLocationKey }
delete next[locationKey] delete next[locationKey]
return { byLocationKey: next } return { byLocationKey: next }}) }))
}) }))
+20 -10
ファイルの表示
@@ -71,16 +71,26 @@ export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
}) })
export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
id: 1, id: 1,
tag: buildTag (), versionNo: 1,
file: null, tag: buildTag (),
url: null, file: null,
wikiPageBody: null, url: null,
contentType: null, wikiPageBody: null,
createdAt: '2026-01-02T03:04:05.000Z', thumbnail: null,
createdByUser: { id: 1, name: 'creator' }, thumbnailFallbackText: 'テストタグ',
updatedAt: '2026-01-03T03:04:05.000Z', thumbnailFallbackKind: 'tag_name',
updatedByUser: { id: 2, name: 'updater' }, mediaKind: 'url_only',
contentType: null,
fileByteSize: null,
fileSuppressedAt: null,
fileSuppressionReason: null,
exportPaths: {},
exportItems: [],
createdAt: '2026-01-02T03:04:05.000Z',
createdByUser: { id: 1, name: 'creator' },
updatedAt: '2026-01-03T03:04:05.000Z',
updatedByUser: { id: 2, name: 'updater' },
...overrides, ...overrides,
}) })
+83 -10
ファイルの表示
@@ -65,17 +65,90 @@ export type FetchNicoTagsOrder = `${ FetchNicoTagsOrderField }:${ 'asc' | 'desc'
export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at' export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at'
export type MaterialFilter = 'present' | 'missing' | 'any'
export type MaterialIndexDirection = 'asc' | 'desc'
export type MaterialIndexMediaKind =
| 'all'
| 'image'
| 'video'
| 'audio'
| 'file_other'
| 'url_only'
export type MaterialIndexSort =
| 'created_at'
| 'updated_at'
| 'tag_name'
| 'media_kind'
| 'file_byte_size'
| 'version_no'
| 'id'
export type MaterialIndexSuppression = 'active' | 'suppressed' | 'all'
export type MaterialIndexTagState = 'all' | 'tagged' | 'untagged'
export type MaterialIndexView = 'card' | 'list'
export type FetchMaterialsParams = {
q: string
tagState: MaterialIndexTagState
mediaKind: MaterialIndexMediaKind
suppression: MaterialIndexSuppression
createdFrom: string
createdTo: string
updatedFrom: string
updatedTo: string
sort: MaterialIndexSort
direction: MaterialIndexDirection
view: MaterialIndexView
page: number
limit: number }
export type Material = { export type Material = {
id: number id: number
tag: Tag versionNo: number
file: string | null tag: Tag | null
url: string | null file: string | null
wikiPageBody?: string | null url: string | null
contentType: string | null wikiPageBody?: string | null
createdAt: string thumbnail: string | null
createdByUser: { id: number; name: string } thumbnailFallbackText: string | null
updatedAt: string thumbnailFallbackKind: 'tag_name' | 'created_at'
updatedByUser: { id: number; name: string } } mediaKind: 'image' | 'video' | 'audio' | 'file_other' | 'url_only' | 'suppressed'
contentType: string | null
fileByteSize: number | null
fileSuppressedAt: string | null
fileSuppressionReason: string | null
exportPaths: Record<string, string>
exportItems: MaterialExportItem[]
createdAt: string
createdByUser: { id: number; name: string }
updatedAt: string
updatedByUser: { id: number; name: string } }
export type MaterialSidebarTag = {
id: number
name: string
category: Category
deprecated: boolean
hasChildren: boolean
hasMaterial: boolean
children: MaterialSidebarTag[] }
export type MaterialTagTree = Omit<Tag, 'children'> & {
hasChildren: boolean
hasMaterial: boolean
children: MaterialTagTree[]
material?: Material | null }
export type MaterialExportItem = {
id: number
profile: string
exportPath: string
enabled: boolean }
export type Menu = MenuItem[] export type Menu = MenuItem[]