コミットを比較

...

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.
- TypeScript and TSX imports may stay on one line if they remain within the
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
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
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
`backend/spec/requests` only when the user explicitly asks for tests.
- 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
under `frontend/src/components` unless existing patterns point elsewhere.
- 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
`!==` over negating a comparison like `!(a === b)`.
- 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
declarations, unless imports, exports, or file boundaries make that awkward.
- 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.
- 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
spans multiple lines.
- Do not put `/>` or `>` on its own line unless the existing surrounding code
+361 -34
ファイルの表示
@@ -1,4 +1,8 @@
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
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -8,54 +12,93 @@ class MaterialsController < ApplicationController
offset = (page - 1) * limit
tag_id = params[:tag_id].presence
parent_id = params[:parent_id].presence
filters = material_index_filters
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 = q.where(tag_id:) if tag_id
q = q.where(parent_id:) if parent_id
q = material_index_join_tag_name(q) if material_index_needs_tag_name?(filters)
q = material_index_join_file_blob(q) if material_index_needs_file_blob?(filters)
q = apply_material_query(q, filters[:q]) if filters[:q].present?
q = apply_material_media_kind(q, filters[:media_kind])
count = q.count
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)
count = q.distinct.count(:id)
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
def show
material =
Material
.includes(:tag)
.includes(:tag, :material_export_items)
.with_attached_thumbnail
.with_attached_file
.find_by(id: params[:id])
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:)
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag_name_raw = params[:tag].to_s.strip
file = params[:file]
file_sha256 = MaterialFileSha256.from_upload(file)
url = params[:url].to_s.strip.presence
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
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)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
material = nil
material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(file)
begin
Material.transaction do
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
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
else
render_validation_error material
@@ -71,29 +114,46 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip
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?
if file.blank? && url.blank?
if file.blank? && url.blank? && !material.file.attached?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
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)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
material.update!(tag:, url:, updated_by_user: current_user)
if file
material.file.attach(file)
else
material.file.purge
begin
Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
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
if material.save
render json: MaterialRepr.base(material, host: request.base_url)
else
render_validation_error material
end
MaterialThumbnailGenerator.generate!(material)
material.reload
render json: MaterialRepr.base(material, host: request.base_url)
end
def destroy
@@ -103,7 +163,274 @@ class MaterialsController < ApplicationController
material = Material.find_by(id: params[:id])
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
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
+93 -34
ファイルの表示
@@ -82,8 +82,9 @@ class TagsController < ApplicationController
def with_depth
parent_tag_id = params[:parent].to_i
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 =
if parent_tag_id
@@ -92,19 +93,9 @@ class TagsController < ApplicationController
visible_root_tag_ids(graph)
end
tags =
Tag
.joins(:tag_name)
.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: [])
}
render json: tag_ids
.sort_by { |tag_id| graph[:tags_by_id][tag_id][:name] }
.map { |tag_id| with_depth_lightweight_repr(tag_id, graph) }
end
def autocomplete
@@ -229,13 +220,16 @@ class TagsController < ApplicationController
def materials_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
material_filter = material_filter_param(default: 'any')
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
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
def update_all
@@ -348,7 +342,14 @@ class TagsController < ApplicationController
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] = [] }
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
@@ -361,20 +362,26 @@ class TagsController < ApplicationController
parent_ids_by_child_id.keys +
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
tags_by_id = Tag.where(id: tag_ids)
.pluck(:id, :category, :deprecated_at)
.each_with_object({ }) do |(id, category, deprecated_at), h|
h[id] = { category:, deprecated: deprecated_at.present? }
material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
tags_by_id = Tag.joins(:tag_name)
.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
{ 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
def visible_root_tag_ids graph
graph[:tags_by_id].filter_map do |tag_id, attrs|
next unless with_depth_visible_tag?(attrs)
graph[:tags_by_id].filter_map do |tag_id, _attrs|
next unless visible_root_tag?(tag_id, graph)
next unless visible_subtree?(tag_id, graph)
tag_id
end
@@ -428,25 +435,77 @@ class TagsController < ApplicationController
return
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
def with_depth_visible_tag? tag
tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
def with_depth_visible_tag? tag, material_filter
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
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
file = nil
content_type = nil
if material&.file&.attached?
file = rails_storage_proxy_url(material.file, only_path: false)
content_type = material.file.blob.content_type
tag_graph = graph && graph[:tags_by_id][tag.id]
material =
nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
children = tag.children.sort_by(&:name)
if graph
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
end
TagRepr.base(tag).merge(
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
material: material.as_json&.merge(file:, content_type:))
children: children.map { build_tag_children(_1, graph:) },
has_material: tag_graph ? tag_graph[:has_material] : material.present?,
material: material && MaterialRepr.base(material, host: request.base_url))
end
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 :created_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 :thumbnail, dependent: :purge
validates :tag_id, presence: true, uniqueness: true
@@ -18,11 +23,30 @@ class Material < ApplicationRecord
validate :tag_must_be_material_category
def content_type
return nil if file_suppressed?
return nil unless file&.attached?
file.blob.content_type
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
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
extend ActiveSupport::Concern
DEFAULT_EVENT_TYPE_MAP = { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }.freeze
def readonly? = persisted?
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
enum :event_type, { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true
enum :event_type, event_type_map, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true
+85 -3
ファイルの表示
@@ -2,7 +2,8 @@
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],
include: { tag: TagRepr::BASE,
created_by_user: UserRepr::BASE,
@@ -12,13 +13,94 @@ module MaterialRepr
def base material, host:
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(
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
def many materials, host:
materials.map { |m| base(m, host:) }
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
+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
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 }"
end
@@ -84,4 +84,5 @@ class VersionRecorder
def snapshot_attributes = raise NotImplementedError
def record_class = @record.class
def event_types = self.class::EVENT_TYPES
end
+6 -1
ファイルの表示
@@ -113,5 +113,10 @@ Rails.application.routes.draw do
resources :skip_events, controller: :theatre_skip_events, only: [:index]
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
+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.
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|
t.string "name", 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
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|
t.bigint "material_id", 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 "updated_at", null: false
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 ["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"], name: "index_material_versions_on_material_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 ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
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
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 "discarded_at"
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 ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
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 ["tag_id"], name: "index_materials_on_tag_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_questions", "gekanator_question_suggestions"
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", column: "parent_id"
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", "tags"
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 "nico_tag_relations", "tags"
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'
RSpec.describe 'Materials API', type: :request do
include ActiveJob::TestHelper
let!(:member_user) { create(:user, :member) }
let!(:admin_user) { create(:user, :admin) }
let!(:guest_user) { create(:user) }
def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
@@ -13,22 +16,29 @@ RSpec.describe 'Materials API', type: :request do
end
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.save!
end
end
describe 'GET /materials' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) }
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) }
let!(:tag_a) do
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
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
end
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
before do
@@ -97,7 +107,9 @@ RSpec.describe 'Materials API', type: :request do
end
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
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
end
@@ -138,9 +150,22 @@ RSpec.describe 'Materials API', type: :request do
end
end
context 'when logged in' do
context 'when logged in but not member' do
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
post '/materials', params: { tag: ' ', file: dummy_upload }
@@ -162,24 +187,49 @@ RSpec.describe 'Materials API', type: :request do
expect do
post '/materials', params: {
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)
.and change(Tag, :count).by(1)
.and change(TagName, :count).by(1)
.and change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:created)
material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_new')
expect(material.tag.category).to eq('material')
expect(material.created_by_user).to eq(guest_user)
expect(material.updated_by_user).to eq(guest_user)
expect(material.created_by_user).to eq(member_user)
expect(material.updated_by_user).to eq(member_user)
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.dig('tag', 'name')).to eq('material_create_new')
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
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(json['url']).to eq('https://example.com/material-source')
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
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
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
end
@@ -277,25 +349,26 @@ RSpec.describe 'Materials API', type: :request do
'tag' => ['タグは必須です.'])
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: {
tag: 'material_update_no_payload'
}
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
expect(response).to have_http_status(:ok)
expect(material.reload.file.attached?).to be(true)
end
it 'updates tag, url, file, and updated_by_user' do
old_blob_id = material.file.blob.id
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
url: 'https://example.com/updated-source',
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg')
}
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
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)
@@ -306,8 +379,15 @@ RSpec.describe 'Materials API', type: :request do
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(true)
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.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['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')
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
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.file.attached?).to be(false)
expect(
ActiveStorage::Blob.where(id: old_blob_id).exists?
).to be(false)
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
expect(json['id']).to eq(material.id)
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['url']).to eq('https://example.com/updated-source')
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
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
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
end
+1 -1
ファイルの表示
@@ -69,7 +69,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
<Route path=":id" element ={<MaterialDetailPage user={user}/>}/>
</Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<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 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 & {
hasChildren: boolean
children: TagWithDepth[] }
const FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
const FILTER_LABELS: Record<MaterialFilter, string> = {
present: '素材あり',
missing: '素材なし',
any: 'すべて'}
const setChildrenById = (
tags: TagWithDepth[],
tags: MaterialSidebarTag[],
targetId: number,
children: TagWithDepth[],
): TagWithDepth[] => (
children: MaterialSidebarTag[]): MaterialSidebarTag[] => (
tags.map (tag => {
if (tag.id === targetId)
return { ...tag, children }
@@ -25,75 +30,285 @@ const setChildrenById = (
if (tag.children.length === 0)
return tag
return { ...tag,
children: (setChildrenById (tag.children, targetId, children)
.filter (t => t.category !== 'meme' || t.hasChildren)) }
return { ...tag, children: setChildrenById (tag.children, targetId, children) }
}))
const MaterialSidebar: FC = () => {
const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
const materialPath = (
tagName: string,
materialFilter: MaterialFilter): string => `/materials?q=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }`
useEffect (() => {
void (async () => {
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
.filter (t => t.category !== 'meme' || t.hasChildren))
}) ()
}, [])
const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => (
ts.map (t => (
<Fragment key={t.id}>
<li>
<div className="flex">
<div className="flex-none w-4">
{t.hasChildren && (
<a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(tagFetchedFlags[t.id]))
{
try
{
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>)))
const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
id: tag.id,
name: tag.name,
category: tag.category,
deprecatedAt: tag.deprecated ? '' : null,
aliases: [],
parents: [],
postCount: 0,
createdAt: '',
updatedAt: '',
hasWiki: false,
materialId: null,
hasDeerjikists: false,
matchedAlias: null })
return (
<SidebarComponent>
<ul>
{renderTags (tags)}
</ul>
</SidebarComponent>)
const updateMaterialFilterQuery = (
pathname: string,
locationSearch: string,
navigate: ReturnType<typeof useNavigate>,
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 (() => {
onError?.({
eventName: 'loadCompleteTimeout',
reason: 'niconico video length was not reported by embed',
})
reason: 'niconico video length was not reported by embed'})
}, LOAD_COMPLETE_TIMEOUT_MS)
}, [clearLoadCompleteTimer, onError])
+1 -2
ファイルの表示
@@ -19,8 +19,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props,
) => (
errors }: Props) => (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
+5 -10
ファイルの表示
@@ -37,8 +37,7 @@ const renderTagTree = (
path: string,
suppressClickRef: MutableRefObject<boolean>,
parentTagId?: number,
sp?: boolean,
): ReactNode[] => {
sp?: boolean): ReactNode[] => {
const key = `${ path }-${ tag.id }`
const self = (
@@ -64,8 +63,7 @@ const renderTagTree = (
const isDescendant = (
root: Tag,
targetId: number,
): boolean => {
targetId: number): boolean => {
if (!(root.children))
return false
@@ -83,8 +81,7 @@ const isDescendant = (
const findTag = (
byCat: TagByCategory,
id: number,
): Tag | undefined => {
id: number): Tag | undefined => {
const walk = (nodes: Tag[]): Tag | undefined => {
for (const t of nodes)
{
@@ -130,8 +127,7 @@ const buildTagByCategory = (post: Post): TagByCategory => {
const changeCategory = async (
tagId: number,
category: Category,
): Promise<void> => {
category: Category): Promise<void> => {
await apiPatch (`/tags/${ tagId }`, { category })
}
@@ -294,8 +290,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
addEventListener ('click', e => {
e.preventDefault ()
e.stopPropagation ()
suppressClickRef.current = false
}, { capture: true, once: true })
suppressClickRef.current = false}, { capture: true, once: true })
}}
onDragCancel={() => {
setActiveTagId (null)
+1 -2
ファイルの表示
@@ -16,8 +16,7 @@ const range = (start: number, end: number): number[] =>
const getPages = (
page: number,
total: number,
siblingCount: number,
): (number | '…')[] => {
siblingCount: number): (number | '…')[] => {
if (total <= 1)
return [1]
+1 -2
ファイルの表示
@@ -103,8 +103,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
choice: options => new Promise (resolve => {
push ({ kind: 'choice',
options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void })
}) }), [push])
resolve: resolve as (value: string | null) => void })}) }), [push])
const active = queue[0]
+23 -31
ファイルの表示
@@ -10,41 +10,35 @@ const buttonVariants = cva (
'rounded-md text-sm font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
].join (' '),
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'].join (' '),
{
variants: {
variant: {
default:
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
default:
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
destructive:
'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
destructive:
'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
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',
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',
secondary:
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
secondary:
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
ghost:
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
ghost:
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300',
},
link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300'},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'}},
defaultVariants: {
variant: 'default',
size: 'default',
},
})
size: 'default'}})
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -57,13 +51,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>)
})
Button.displayName = "Button"
export { Button, buttonVariants }
+10 -20
ファイルの表示
@@ -22,11 +22,9 @@ const DialogOverlay = React.forwardRef<
ref={ref}
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",
className
)}
className)}
{...props}
/>
))
/>))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
@@ -62,8 +60,7 @@ const DialogContent = React.forwardRef<
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
</DialogPortal>))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
@@ -73,11 +70,9 @@ const DialogHeader = ({
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
className)}
{...props}
/>
)
/>)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
@@ -87,11 +82,9 @@ const DialogFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
className)}
{...props}
/>
)
/>)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
@@ -102,11 +95,9 @@ const DialogTitle = React.forwardRef<
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
className)}
{...props}
/>
))
/>))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
@@ -117,8 +108,7 @@ const DialogDescription = React.forwardRef<
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
/>))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
+19 -22
ファイルの表示
@@ -1,22 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
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",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
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",
className )}
ref={ref}
{...props}
/> )
} )
Input.displayName = "Input"
export { Input }
+26 -29
ファイルの表示
@@ -1,29 +1,26 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
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",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
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"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
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",
className )}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
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" )}
/>
</SwitchPrimitives.Root> ))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+115 -129
ファイルの表示
@@ -1,129 +1,115 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
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]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
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",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
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",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
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",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
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]",
className )}
{...props}
/> ))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
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",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: { variant: "default" } } )
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/> ) })
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
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",
className )}
{...props}
/> ))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
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",
className )}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close> ))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/> ))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/> ))
ToastDescription.displayName = ToastPrimitives.Description.displayName
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'
import { useToast } from '@/components/ui/use-toast'
import { Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport } from '@/components/ui/toast'
export const Toaster = () => {
const { toasts } = useToast ()
return (
<ToastProvider>
{toasts.map (({ id, title, description, action, ...props }) => (
<Toast key={id}
className="bg-gray-300/80 dark:bg-gray-700/80"
{...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>))}
<ToastViewport />
</ToastProvider>
)
}
'use client'
import { useToast } from '@/components/ui/use-toast'
import { Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport } from '@/components/ui/toast'
export const Toaster = () => {
const { toasts } = useToast ()
return (
<ToastProvider>
{toasts.map (({ id, title, description, action, ...props }) => (
<Toast key={id}
className="bg-gray-300/80 dark:bg-gray-700/80"
{...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>))}
<ToastViewport />
</ToastProvider> )
}
+28 -42
ファイルの表示
@@ -58,8 +58,7 @@ const addToRemoveQueue = (toastId: string) => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
toastId: toastId})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
@@ -69,17 +68,14 @@ export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t)}
case "DISMISS_TOAST": {
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,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false}
: t)}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
return {
...state,
toasts: []}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId)}
}
}
@@ -139,8 +130,7 @@ function toast({ ...props }: Toast) {
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
toast: { ...props, id }})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
@@ -150,16 +140,13 @@ function toast({ ...props }: Toast) {
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
if (!open) dismiss()
}}})
return {
id: id,
dismiss,
update,
}
update}
}
function useToast() {
@@ -170,7 +157,7 @@ function useToast() {
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
listeners.splice(index, 1)
}
}
}, [state])
@@ -178,8 +165,7 @@ function useToast() {
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId })}
}
export { useToast, toast }
+1 -2
ファイルの表示
@@ -3,8 +3,7 @@ const ENV: string = 'development'
const config = {
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 SITE_TITLE = config.SITE_TITLE
+5 -10
ファイルの表示
@@ -10,8 +10,7 @@ export const CATEGORIES = [
'general',
'material',
'meta',
'nico',
] as const
'nico'] as const
export const CATEGORY_NAMES: Record<Category, string> = {
deerjikist: 'ニジラー',
@@ -20,16 +19,14 @@ export const CATEGORY_NAMES: Record<Category, string> = {
general: '一般',
material: '素材',
meta: 'メタタグ',
nico: 'ニコニコタグ',
} as const
nico: 'ニコニコタグ'} as const
export const FETCH_POSTS_ORDER_FIELDS = [
'title',
'url',
'original_created_at',
'created_at',
'updated_at',
] as const
'updated_at'] as const
export const PLATFORMS = ['nico', 'youtube'] as const
@@ -43,13 +40,11 @@ export const TAG_COLOUR = {
general: 'cyan',
material: 'orange',
meta: 'yellow',
nico: 'gray',
} as const satisfies Record<Category, string>
nico: 'gray'} as const satisfies Record<Category, string>
export const USER_ROLES = ['admin', 'member', 'guest'] as const
export const ViewFlagBehavior = {
OnShowedDetail: 1,
OnClickedLink: 2,
NotAuto: 3,
} as const
NotAuto: 3} as const
+6 -12
ファイルの表示
@@ -23,8 +23,7 @@ const apiP = async <T> (
method: 'post' | 'put' | 'patch',
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => {
opt?: Opt): Promise<T> => {
const res = await client[method] (path, body ?? { }, withUserCode (opt))
if (opt?.responseType === 'blob')
return res.data as T
@@ -34,8 +33,7 @@ const apiP = async <T> (
export const apiGet = async <T> (
path: string,
opt?: Opt,
): Promise<T> => {
opt?: Opt): Promise<T> => {
const res = await client.get (path, withUserCode (opt))
if (opt?.responseType === 'blob')
return res.data as T
@@ -46,28 +44,24 @@ export const apiGet = async <T> (
export const apiPost = async <T> (
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => apiP ('post', path, body, opt)
opt?: Opt): Promise<T> => apiP ('post', path, body, opt)
export const apiPut = async <T> (
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => apiP ('put', path, body, opt)
opt?: Opt): Promise<T> => apiP ('put', path, body, opt)
export const apiPatch = async <T> (
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => apiP ('patch', path, body, opt)
opt?: Opt): Promise<T> => apiP ('patch', path, body, opt)
export const apiDelete = async <T = void> (
path: string,
opt?: Opt,
): Promise<T> => {
opt?: Opt): Promise<T> => {
const res = await client.delete (path, withUserCode (opt))
if (res.data == null || res.data === '')
return undefined as T
+14 -28
ファイルの表示
@@ -104,8 +104,7 @@ export type BuildGekanatorQuestionsOptions = {
export const normalizeTitleLengthCondition = (
condition: GekanatorQuestionCondition,
): GekanatorQuestionCondition => {
condition: GekanatorQuestionCondition): GekanatorQuestionCondition => {
switch (condition.type)
{
case 'title-length-greater-than':
@@ -119,8 +118,7 @@ export const normalizeTitleLengthCondition = (
export const titleLengthMinimumForCondition = (
condition: GekanatorQuestionCondition,
): number | null => {
condition: GekanatorQuestionCondition): number | null => {
switch (condition.type)
{
case 'title-length-at-least':
@@ -134,8 +132,7 @@ export const titleLengthMinimumForCondition = (
export const questionIdForCondition = (
condition: NonPostSimilarityCondition,
): string => {
condition: NonPostSimilarityCondition): string => {
switch (condition.type)
{
case 'tag':
@@ -161,8 +158,7 @@ export const questionIdForCondition = (
const directExampleAnswerFor = (
question: StoredGekanatorQuestion,
post: Post,
): GekanatorAnswerValue | null => {
post: Post): GekanatorAnswerValue | null => {
if (question.kind !== 'post_similarity' && question.kind !== 'tag')
return null
@@ -178,15 +174,13 @@ const directExampleAnswerFor = (
export const isLearnedSemanticQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion,
): boolean =>
question: StoredGekanatorQuestion | GekanatorQuestion): boolean =>
question.kind === 'post_similarity'
&& question.source === 'user_suggested'
export const learnedSemanticSideForAnswer = (
answer: GekanatorAnswerValue | null,
): LearnedSemanticSide => {
answer: GekanatorAnswerValue | null): LearnedSemanticSide => {
if (answer === 'yes' || answer === 'partial')
return 'positive'
@@ -314,8 +308,7 @@ const questionableTag = (post: Post, key: string): boolean => {
const questionMatches = (
post: Post,
question: StoredGekanatorQuestion,
): boolean => {
question: StoredGekanatorQuestion): boolean => {
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return question.kind === 'post_similarity'
@@ -350,8 +343,7 @@ const questionMatches = (
export const expectedAnswerForQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): GekanatorAnswerValue | null => {
post: Post | null): GekanatorAnswerValue | null => {
if (!(question) || !(post))
return null
@@ -382,14 +374,12 @@ export const expectedAnswerForQuestion = (
export const learnedSemanticSideForPost = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): LearnedSemanticSide =>
post: Post | null): LearnedSemanticSide =>
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion,
): GekanatorQuestion => {
question: StoredGekanatorQuestion): GekanatorQuestion => {
const normalizedCondition = normalizeTitleLengthCondition (question.condition)
const normalizedQuestion = {
...question,
@@ -408,8 +398,7 @@ export const restoreGekanatorQuestion = (
export const storeGekanatorQuestion = (
question: GekanatorQuestion,
): StoredGekanatorQuestion => ({
question: GekanatorQuestion): StoredGekanatorQuestion => ({
id: question.condition.type === 'title-length-greater-than'
? `title:length-at-least:${ question.condition.length + 1 }`
: question.id,
@@ -436,8 +425,7 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
export const fetchGekanatorExtraQuestions = async (
gameId: number,
nonce?: string,
): Promise<GekanatorExtraQuestion[]> => {
nonce?: string): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined })
@@ -447,8 +435,7 @@ export const fetchGekanatorExtraQuestions = async (
export const buildGekanatorQuestions = (
posts: Post[],
options: BuildGekanatorQuestionsOptions = { },
): GekanatorQuestion[] => {
options: BuildGekanatorQuestionsOptions = { }): GekanatorQuestion[] => {
const {
includeTitleContains = true,
tagQuestionCap = 192,
@@ -490,8 +477,7 @@ export const buildGekanatorQuestions = (
const usefulEntries = <T extends string | number> (
counts: Map<T, number>,
cap: number,
) =>
cap: number) =>
[...counts.entries ()]
.filter (([, count]) => count > 0 && count < posts.length)
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
+4 -8
ファイルの表示
@@ -32,8 +32,7 @@ export const candidatePostsFor = (
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
): Post[] => {
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
@@ -76,8 +75,7 @@ export const candidatePostsFor = (
export const hardFilteredPostsForAnswer = (
{ posts, question, answer }: { posts: Post[]
question: GekanatorQuestion
answer: GekanatorAnswerValue },
): Post[] => {
answer: GekanatorAnswerValue }): Post[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return posts
@@ -98,8 +96,7 @@ const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', '
export const allConcreteAnswerOptionsExhausted = (
posts: Post[],
question: GekanatorQuestion | null,
): boolean => {
question: GekanatorQuestion | null): boolean => {
if (!(question))
return false
@@ -125,8 +122,7 @@ export const recoverCandidatePosts = (
recoveredCandidatePosts: Map<number, RecoveredCandidateState>
eligiblePostIds: Set<number>
answerCountAtRecovery: number
recoveryStepCount: number },
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number }): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number } | null => {
const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount)
+8 -16
ファイルの表示
@@ -8,8 +8,7 @@ import type {
export const monthForCondition = (
condition: GekanatorQuestion['condition'],
): number | null => {
condition: GekanatorQuestion['condition']): number | null => {
if (condition.type === 'original-month')
return condition.month
@@ -24,8 +23,7 @@ export const monthForCondition = (
const isTitleLengthContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
const candidateLength = titleLengthMinimumForCondition (candidate)
const previousLength = titleLengthMinimumForCondition (previous)
if (candidateLength === null || previousLength === null)
@@ -45,8 +43,7 @@ const isTitleLengthContradiction = (
const isQuestionRedundantAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
const previous = answer.questionCondition
return previous !== undefined
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
@@ -56,8 +53,7 @@ const isQuestionRedundantAfterAnswers = (
const isSourceFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
if (candidate.type !== 'source' || previous.type !== 'source')
return false
@@ -76,8 +72,7 @@ const isSourceFactBlocked = (
const isOriginalYearFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
if (candidate.type !== 'original-year' || previous.type !== 'original-year')
return false
@@ -96,8 +91,7 @@ const isOriginalYearFactBlocked = (
const isOriginalMonthFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
switch (answer)
{
case 'yes':
@@ -143,8 +137,7 @@ const isOriginalMonthFactBlocked = (
const isFactQuestionBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
if (!(answer === 'yes' || answer === 'no'))
return false
@@ -156,8 +149,7 @@ const isFactQuestionBlocked = (
export const isQuestionHardFilteredAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
const previous = answer.questionCondition
if (previous === undefined)
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 (
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
): Promise<{
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams): Promise<{
posts: Post[]
count: number }> =>
await apiGet ('/posts', { params: {
@@ -33,8 +32,7 @@ export const fetchPostChanges = async (
post?: string
tag?: string
page: number
limit: number },
): Promise<{
limit: number }): Promise<{
versions: PostVersion[]
count: number }> =>
await apiGet ('/posts/versions', { params: { ...(post && { post }),
@@ -52,8 +50,7 @@ export const updatePost = async (
{ baseVersionNo, force, merge }: {
baseVersionNo?: number
force?: boolean
merge?: boolean }
) =>
merge?: boolean }) =>
await apiPut<Post> (
`/posts/${ post.id }`,
{ 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 = {
root: ['posts'] as const,
@@ -25,6 +31,19 @@ export const tagsKeys = {
['tags', 'changes', p] 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 = {
root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
+4 -8
ファイルの表示
@@ -11,8 +11,7 @@ import type { Deerjikist,
export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, deprecated,
page, limit, order }: FetchTagsParams,
): Promise<{ tags: Tag[]
page, limit, order }: FetchTagsParams): Promise<{ tags: Tag[]
count: number }> =>
await apiGet ('/tags', { params: {
...(post != null && { post }),
@@ -31,8 +30,7 @@ export const fetchTags = async (
export const fetchNicoTags = async (
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams,
): Promise<{ tags: NicoTag[]
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams): Promise<{ tags: NicoTag[]
count: number }> =>
await apiGet ('/tags/nico', { params: {
page,
@@ -70,14 +68,12 @@ export const fetchTagChanges = async (
{ id, page, limit }: {
id?: string
page: number
limit: number },
): Promise<{
limit: number }): Promise<{
versions: TagVersion[]
count: number }> =>
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } })
export const fetchDeerjikistsByTag = async (
id: string,
): Promise<{ tag: Tag; deerjikists: Deerjikist[]}> =>
id: string): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
await apiGet (`/tags/${ id }/deerjikists`)
+1 -2
ファイルの表示
@@ -4,5 +4,4 @@ const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
export const canEditContent = (
user: Pick<User, 'role'> | null | undefined,
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
user: Pick<User, 'role'> | null | undefined): 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 = (
d: string | Date,
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null,
): string =>
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null): string =>
toDate (d).toLocaleString (
'ja-JP-u-ca-japanese',
{ era: 'long',
@@ -28,8 +27,7 @@ export const dateString = (
export const originalCreatedAtString = (
f: string | Date | null,
b: string | Date | null,
): string => {
b: string | Date | null): string => {
const from = f ? toDate (f) : null
const before = b ? toDate (b) : null
+3 -6
ファイルの表示
@@ -4,22 +4,19 @@ import type { WikiPage } from '@/types'
export const fetchWikiPages = async (
{ title }: { title?: string },
): Promise<WikiPage[]> =>
{ title }: { title?: string }): Promise<WikiPage[]> =>
await apiGet ('/wiki', { params: { title } })
export const fetchWikiPage = async (
id: string,
{ version }: { version?: string },
): Promise<WikiPage> =>
{ version }: { version?: string }): Promise<WikiPage> =>
await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } })
export const fetchWikiPageByTitle = async (
title: string,
{ version }: { version?: string },
): Promise<WikiPage | null> => {
{ version }: { version?: string }): Promise<WikiPage | null> => {
try
{
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
+73 -146
ファイルの表示
@@ -238,8 +238,7 @@ const createGameSeed = (): string => {
const normalizeStoredQuestionId = (
questionId: string,
condition?: GekanatorQuestionCondition,
): string => {
condition?: GekanatorQuestionCondition): string => {
if (condition?.type === 'title-length-greater-than')
return `title:length-at-least:${ condition.length + 1 }`
@@ -312,8 +311,7 @@ const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
const shouldReplaceMergedQuestion = (
current: GekanatorQuestion | undefined,
candidate: GekanatorQuestion,
): boolean => {
candidate: GekanatorQuestion): boolean => {
if (!(current))
return true
@@ -408,8 +406,7 @@ const loadRecentGames = (): RecentGameSummary[] => {
const storeRecentGameSummary = (
summary: RecentGameSummary,
): RecentGameSummary[] => {
summary: RecentGameSummary): RecentGameSummary[] => {
const next =
[summary,
...loadRecentGames ().filter (item => (item.savedAt !== summary.savedAt
@@ -459,8 +456,7 @@ const resettableExtraQuestionState = (): {
const recoveredCandidateMapFromStored = (
items: RecoveredCandidatePost[],
scores: [number, number][],
): Map<number, RecoveredCandidateState> => {
scores: [number, number][]): Map<number, RecoveredCandidateState> => {
const storedScores = new Map (scores)
return new Map (items.map (item => [item.postId, {
@@ -470,8 +466,7 @@ const recoveredCandidateMapFromStored = (
const storedRecoveredCandidatesFromMap = (
recoveredCandidatePosts: Map<number, RecoveredCandidateState>,
): RecoveredCandidatePost[] =>
recoveredCandidatePosts: Map<number, RecoveredCandidateState>): RecoveredCandidatePost[] =>
[...recoveredCandidatePosts.entries ()]
.map (([postId, recoveredCandidate]) => ({
postId,
@@ -513,8 +508,7 @@ const distributionEntropy = (weights: number[]): number =>
const questionCategoryPenalty = (
question: GekanatorQuestion,
answerCount: number,
repeatPenalty: number,
): number => {
repeatPenalty: number): number => {
const earlyFactor = Math.max (0, (3 - answerCount) / 3)
const titleLengthPenalty = (() => {
if (titleLengthMinimumForCondition (question.condition) == null)
@@ -553,8 +547,7 @@ const relatedPostIdsOf = (post: Post): number[] => {
const userPriorWeightsFor = (
posts: Post[],
recentGames: RecentGameSummary[],
): Map<number, number> => {
recentGames: RecentGameSummary[]): Map<number, number> => {
const postById = new Map (posts.map (post => [post.id, post]))
const weights = new Map<number, number> ()
const addWeight = (postId: number, weight: number) => {
@@ -581,14 +574,12 @@ const userPriorWeightsFor = (
const answerWeightFor = (
questionId: string,
softenedQuestionIds: Set<string>,
): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
softenedQuestionIds: Set<string>): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
const scoreWeightForAnswer = (
answer: GekanatorAnswerLog,
softenedQuestionIds: Set<string>,
): number =>
softenedQuestionIds: Set<string>): number =>
answerWeightFor (answer.questionId, softenedQuestionIds)
* (
answer.questionPurpose === 'learning_user_suggested'
@@ -638,8 +629,7 @@ const titleTermPattern =
const addPostIdToIndex = <K extends string | number> (
index: Map<K, Set<number>>,
key: K,
postId: number,
) => {
postId: number) => {
const current = index.get (key)
if (current)
{
@@ -652,8 +642,7 @@ const addPostIdToIndex = <K extends string | number> (
const buildMaterialIndex = (
posts: Post[],
): GekanatorQuestionMaterialIndex => {
posts: Post[]): GekanatorQuestionMaterialIndex => {
const postById = new Map<number, Post> ()
const tagKeysByPostId = new Map<number, string[]> ()
const postIdsByTagKey = new Map<string, Set<number>> ()
@@ -784,8 +773,7 @@ const originalDateQuestionTextFor = (
condition: Extract<
GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' }
>,
): string => {
>): string => {
switch (condition.type)
{
case 'original-year':
@@ -852,8 +840,7 @@ const isLearnableTagKey = (key: string): boolean => !(key.startsWith ('nico:'))
const isUserSuggestedLearnedSemanticQuestion = (
question: GekanatorQuestion,
): boolean => isLearnedSemanticQuestion (question)
question: GekanatorQuestion): boolean => isLearnedSemanticQuestion (question)
type LearnedSemanticCandidateStats = {
@@ -872,8 +859,7 @@ const learnedSemanticStatsForCandidateIds = (
question }: {
candidateIds: number[]
posts: Post[]
question: GekanatorQuestion },
): LearnedSemanticCandidateStats => {
question: GekanatorQuestion }): LearnedSemanticCandidateStats => {
const candidateIdSet = new Set (candidateIds)
const positiveIds = new Set<number> ()
const negativeIds = new Set<number> ()
@@ -915,8 +901,7 @@ const learnedSemanticQuestionIsEffectiveForCandidateIds = (
question }: {
candidateIds: number[]
posts: Post[]
question: GekanatorQuestion },
): boolean => {
question: GekanatorQuestion }): boolean => {
if (!(isUserSuggestedLearnedSemanticQuestion (question)))
return false
@@ -936,8 +921,7 @@ const learnedSemanticQuestionIsEffectiveForCandidateIds = (
const directSemanticAnswerForPost = (
question: GekanatorQuestion,
post: Post,
): GekanatorAnswerValue | null => {
post: Post): GekanatorAnswerValue | null => {
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
if (direct)
return direct
@@ -956,8 +940,7 @@ const learnedSemanticLearningValueForTopPosts = (
question: GekanatorQuestion
learningTargetPosts: Post[]
candidateIds: number[]
posts: Post[] },
): { missingTopCount: number
posts: Post[] }): { missingTopCount: number
knownCount: number
hasLearningValue: boolean } => {
const missingTopCount =
@@ -1000,8 +983,7 @@ const learningTargetPostsForCandidates = ({
const questionPurposeCountsFor = (
answers: GekanatorAnswerLog[],
): {
answers: GekanatorAnswerLog[]): {
effectiveUserSuggestedCount: number
learningUserSuggestedCount: number
normalQuestionCount: number
@@ -1041,8 +1023,7 @@ const questionPurposeCountsFor = (
const learnedSemanticNarrowPenaltyForStats = (
candidateCount: number,
stats: LearnedSemanticCandidateStats,
): number => {
stats: LearnedSemanticCandidateStats): number => {
const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08)
return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0
}
@@ -1050,8 +1031,7 @@ const learnedSemanticNarrowPenaltyForStats = (
const learnedSemanticScoreDeltaForExpectedAnswer = (
userAnswer: GekanatorAnswerValue,
expectedAnswer: GekanatorAnswerValue | null,
): number => {
expectedAnswer: GekanatorAnswerValue | null): number => {
switch (userAnswer)
{
case 'yes':
@@ -1089,8 +1069,7 @@ const learnedSemanticScoreDeltaForExpectedAnswer = (
const scoreDropDeltaForRecoveredPost = (
postId: number,
totalScore: number,
recoveredCandidatePosts: Map<number, RecoveredCandidateState>,
): number => {
recoveredCandidatePosts: Map<number, RecoveredCandidateState>): number => {
const recoveredCandidate = recoveredCandidatePosts.get (postId)
if (recoveredCandidate == null)
return totalScore
@@ -1114,8 +1093,7 @@ const postPassesScoreDrop = (
recoveredCandidatePosts }: {
postId: number
scores: Map<number, number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
): boolean => {
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): boolean => {
if (!(activeCandidateScoreDropEnabled (scores)))
return true
@@ -1129,8 +1107,7 @@ const postPassesScoreDrop = (
// `post_similarities` is the score-propagation graph, not the question kind.
const questionUsesPostSimilarityPropagationGraphForScoring = (
question: GekanatorQuestion,
): boolean =>
question: GekanatorQuestion): boolean =>
(question.kind === 'post_similarity'
&& !(isUserSuggestedLearnedSemanticQuestion (question)))
|| (question.kind === 'tag'
@@ -1139,8 +1116,7 @@ const questionUsesPostSimilarityPropagationGraphForScoring = (
const questionSupportsAnswerBasedHardFiltering = (
question: GekanatorQuestion,
): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
question: GekanatorQuestion): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
&& !(isUserSuggestedLearnedSemanticQuestion (question))
@@ -1160,8 +1136,7 @@ const usesLearnedTagExamples = (question: GekanatorQuestion): boolean =>
const searchedQuestionsFor = (
questions: GekanatorQuestion[],
search: string,
): GekanatorQuestion[] => {
search: string): GekanatorQuestion[] => {
const needle = search.trim ()
if (!(needle))
return []
@@ -1235,8 +1210,7 @@ type QuestionMatchResolver = {
const buildGekanatorMatchIndex = (
posts: Post[],
questions: GekanatorQuestion[],
): GekanatorMatchIndex => new Map (
questions: GekanatorQuestion[]): GekanatorMatchIndex => new Map (
questions.map (question => [
question.id,
new Set (
@@ -1285,8 +1259,7 @@ const matchingPostIdsForQuestion = ({
const positiveMatchingPostIdsForQuestion = (
resolver: QuestionMatchResolver,
): Set<number> => {
resolver: QuestionMatchResolver): Set<number> => {
if (isUserSuggestedLearnedSemanticQuestion (resolver.question))
{
const cached = resolver.dynamicMatchIndex?.get (resolver.question.id)
@@ -1356,8 +1329,7 @@ const matchingWeightInCandidates = (
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex
question: GekanatorQuestion
dynamicMatchIndex?: GekanatorMatchIndex },
): number => {
dynamicMatchIndex?: GekanatorMatchIndex }): number => {
const matched = positiveMatchingPostIdsForQuestion ({
posts,
materialIndex,
@@ -1380,8 +1352,7 @@ const signatureForCandidateIds = (
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex
question: GekanatorQuestion
dynamicMatchIndex?: GekanatorMatchIndex },
): string => {
dynamicMatchIndex?: GekanatorMatchIndex }): string => {
if (isUserSuggestedLearnedSemanticQuestion (question))
{
const postById = new Map (posts.map (post => [post.id, post]))
@@ -1421,8 +1392,7 @@ const postIdsForHardAnswer = (
posts: Post[]
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex
dynamicMatchIndex?: GekanatorMatchIndex },
): number[] => {
dynamicMatchIndex?: GekanatorMatchIndex }): number[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return candidateIds
@@ -1562,8 +1532,7 @@ const buildIndexedQuestion = (
text: string
kind: GekanatorQuestionKind
priorityWeight: number
materialIndex: GekanatorQuestionMaterialIndex },
): GekanatorQuestion => ({
materialIndex: GekanatorQuestionMaterialIndex }): GekanatorQuestion => ({
id: questionIdForCondition (condition),
text,
kind,
@@ -1578,8 +1547,7 @@ const buildIndexedQuestion = (
const rankedEntriesForCounts = <T extends string | number> (
{ counts, total, cap }: { counts: Map<T, number>
total: number
cap: number },
): [T, number][] =>
cap: number }): [T, number][] =>
([...counts.entries ()]
.filter (([, count]) => count > 0 && count < total)
.sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1]))
@@ -1594,8 +1562,7 @@ const buildQuestionsForCandidateIds = (
materialIndex: GekanatorQuestionMaterialIndex
acceptedQuestions: GekanatorQuestion[]
mode?: QuestionBuildMode
confirmationPostId?: number | null },
): GekanatorQuestion[] => {
confirmationPostId?: number | null }): GekanatorQuestion[] => {
const total = candidateIds.length
const confirmationPost = (() => {
if (confirmationPostId == null)
@@ -1652,8 +1619,7 @@ const buildQuestionsForCandidateIds = (
condition: Extract<
GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' }
>,
): GekanatorQuestion => {
>): GekanatorQuestion => {
const priorityWeight = (() => {
if (condition.type === 'original-year')
return 1.04
@@ -1673,8 +1639,7 @@ const buildQuestionsForCandidateIds = (
const specialMonthDays = rankedEntriesForCounts ({
counts: monthDayCounts,
total,
cap: factCap
}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
cap: factCap}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
if (mode === 'split')
{
@@ -2124,8 +2089,7 @@ type ExclusiveConditionGroup =
const exclusiveConditionGroupFor = (
condition: GekanatorQuestion['condition'],
): ExclusiveConditionGroup | null => {
condition: GekanatorQuestion['condition']): ExclusiveConditionGroup | null => {
switch (condition.type)
{
case 'original-month':
@@ -2144,8 +2108,7 @@ const exclusiveConditionGroupFor = (
const sameConditionValue = (
left: GekanatorQuestion['condition'],
right: GekanatorQuestion['condition'],
): boolean => {
right: GekanatorQuestion['condition']): boolean => {
const leftTitleLength = titleLengthMinimumForCondition (left)
const rightTitleLength = titleLengthMinimumForCondition (right)
if (leftTitleLength != null || rightTitleLength != null)
@@ -2187,8 +2150,7 @@ const sameConditionValue = (
const isMonthCrossMatch = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
): boolean => {
previous: GekanatorQuestion['condition']): boolean => {
const candidateMonth = monthForCondition (candidate)
const previousMonth = monthForCondition (previous)
if (candidateMonth == null || previousMonth == null)
@@ -2204,8 +2166,7 @@ const isMonthCrossMatch = (
const isExclusiveContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
): boolean => {
previous: GekanatorQuestion['condition']): boolean => {
const candidateGroup = exclusiveConditionGroupFor (candidate)
const previousGroup = exclusiveConditionGroupFor (previous)
@@ -2242,16 +2203,14 @@ const contradictionPenaltyFor = ({
case 'no':
if (
sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous)
)
|| isMonthCrossMatch (question.condition, previous))
return sum + 40
return sum
case 'probably_no':
if (
sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous)
)
|| isMonthCrossMatch (question.condition, previous))
return sum + 20
return sum
@@ -2281,8 +2240,7 @@ const chooseQuestion = (
recentFirstQuestionPenaltyById: Map<string, number>
userPriorWeights: Map<number, number>
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex },
): QuestionSelection | null => {
matchIndex: GekanatorMatchIndex }): QuestionSelection | null => {
const dynamicMatchIndex = new Map<string, Set<number>> ()
const invertedSignature = (signature: string): string =>
@@ -2327,8 +2285,7 @@ const chooseQuestion = (
const rank = (
questionsToRank: GekanatorQuestion[],
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 candidateById = new Map (candidates.map (item => [item.post.id, item.post]))
const candidateIds = candidates.map (item => item.post.id)
@@ -2613,8 +2570,7 @@ const chooseQuestion = (
if (
effectiveRatio < targetEffectiveUserSuggestedQuestionRatio
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
&& effectiveUserSuggestedPool.length > 0
)
&& effectiveUserSuggestedPool.length > 0)
{
selectedPool = effectiveUserSuggestedPool
selectedPurpose = 'effective_user_suggested'
@@ -2622,8 +2578,7 @@ const chooseQuestion = (
else if (
learningRatio < targetLearningUserSuggestedQuestionRatio
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
&& learningUserSuggestedPool.length > 0
)
&& learningUserSuggestedPool.length > 0)
{
selectedPool = learningUserSuggestedPool
selectedPurpose = 'learning_user_suggested'
@@ -2683,8 +2638,7 @@ const chooseQuestion = (
const winningRunPriorityFor = (
expected: GekanatorAnswerValue,
): number | null => {
expected: GekanatorAnswerValue): number | null => {
if (expected === 'yes')
return 0
if (expected === 'partial')
@@ -2719,8 +2673,7 @@ const chooseWinningRunQuestion = ({
materialIndex,
acceptedQuestions,
mode: 'confirmation',
confirmationPostId: targetPost.id
})
confirmationPostId: targetPost.id})
.filter (question => {
if (askedIds.has (question.id))
return false
@@ -2829,8 +2782,7 @@ const chooseFallbackQuestion = ({
materialIndex,
acceptedQuestions: [],
mode: 'confirmation',
confirmationPostId: post.id
})))
confirmationPostId: post.id})))
.slice (0, 32)
const dynamicMatchIndex = new Map<string, Set<number>> ()
const ranked = mergeQuestions ([
@@ -2905,8 +2857,7 @@ const chooseFallbackQuestion = ({
const shouldEnterGuessPhase = (
reason: GuessReason | null,
): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
reason: GuessReason | null): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
(reason === 'hard_max_questions'
|| reason === 'winning_run_finished'
|| reason === 'question_count_checkpoint')
@@ -2914,15 +2865,13 @@ const shouldEnterGuessPhase = (
const isWinningRunActive = (
winningRunTargetId: number | null,
winningRunStartAnswerCount: number | null,
): boolean =>
winningRunStartAnswerCount: number | null): boolean =>
winningRunTargetId != null && winningRunStartAnswerCount != null
const winningRunQuestionCount = (
answers: GekanatorAnswerLog[],
winningRunStartAnswerCount: number | null,
): number => {
winningRunStartAnswerCount: number | null): number => {
if (winningRunStartAnswerCount == null)
return 0
@@ -2962,8 +2911,7 @@ const nextQuestionPlanFor = (
matchIndex: GekanatorMatchIndex
lastGuessQuestionCount: number
winningRunTargetId: number | null
winningRunStartAnswerCount: number | null },
): { question: GekanatorQuestion | null
winningRunStartAnswerCount: number | null }): { question: GekanatorQuestion | null
guess: Post | null
guessReason: GuessReason | null
questionMode: QuestionMode
@@ -3013,8 +2961,7 @@ const nextQuestionPlanFor = (
if (
isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
&& winningRunTargetId === nextWinningRunTargetId
&& winningRunStartAnswerCount != null
)
&& winningRunStartAnswerCount != null)
return winningRunStartAnswerCount
return answers.length
@@ -3188,8 +3135,7 @@ const mascotStateFor = (
resultWon: boolean | null,
eligiblePostCount: number,
bestConfidencePercent: number,
winningRunActive: boolean,
): MascotState => {
winningRunActive: boolean): MascotState => {
const resultPhase =
phase === 'end'
|| phase === 'review'
@@ -3210,13 +3156,11 @@ const mascotStateFor = (
if (
winningRunActive
|| eligiblePostCount <= 2
|| bestConfidencePercent >= 70
)
|| bestConfidencePercent >= 70)
return 'thinking_near'
if (
eligiblePostCount >= 15
&& bestConfidencePercent < 45
)
&& bestConfidencePercent < 45)
return 'thinking_far'
return 'thinking_mid'
case 'guess':
@@ -3327,8 +3271,7 @@ const GekanatorBackdrop: FC<{
const settingsForMode = useCallback (
(
mode: 'normal' | 'winning_run' | 'guess',
): { columns: number; rows: number; opacity: number } => {
mode: 'normal' | 'winning_run' | 'guess'): { columns: number; rows: number; opacity: number } => {
if (mode === 'winning_run' || mode === 'guess')
return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 }
@@ -3342,8 +3285,7 @@ const GekanatorBackdrop: FC<{
const scaleForMode = useCallback (
(
mode: 'normal' | 'winning_run' | 'guess',
displayedWinningCount: number,
): number => {
displayedWinningCount: number): number => {
if (mode === 'guess')
return 8
@@ -3355,8 +3297,7 @@ const GekanatorBackdrop: FC<{
[])
const postsForMode = useCallback ((
mode: 'normal' | 'winning_run' | 'guess',
): Post[] => {
mode: 'normal' | 'winning_run' | 'guess'): Post[] => {
if (mode === 'guess' && displayedGuess)
return [displayedGuess]
if (mode === 'winning_run' && winningRunTargetPost)
@@ -3366,8 +3307,7 @@ const GekanatorBackdrop: FC<{
const thumbnailsForMode = useCallback ((
mode: 'normal' | 'winning_run' | 'guess',
count: number,
): string[] => {
count: number): string[] => {
const modePosts = postsForMode (mode)
if (modePosts.length === 0)
return []
@@ -3734,8 +3674,7 @@ const GekanatorBackdrop: FC<{
const expectedAnswerFor = (
question: GekanatorQuestion | undefined,
correctPost: Post | null,
): GekanatorAnswerValue | null =>
correctPost: Post | null): GekanatorAnswerValue | null =>
expectedAnswerForQuestion (question, correctPost)
@@ -4169,8 +4108,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (true)
setSavedGameId (data.id)
setLearnedExampleCount (data.learnedExampleCount)
setResultWon (variables.guessedPostId === variables.correctPostId)
}})
setResultWon (variables.guessedPostId === variables.correctPostId)}})
const questionSuggestionMutation = useMutation ({
mutationFn: saveGekanatorQuestionSuggestion,
onSuccess: async data => {
@@ -4180,15 +4118,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
}})
setQuestionSuggestionAnswer ('yes')}})
const extraQuestionAnswersMutation = useMutation ({
mutationFn: saveGekanatorExtraQuestionAnswers,
onSuccess: async () => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
setExtraQuestionState ('saved')
setPhase ('end')
}})
setPhase ('end')}})
const resetExtraQuestionState = () => {
const next = resettableExtraQuestionState ()
@@ -4330,8 +4266,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
!(allowPreQuestionRecovery)
|| recoveredEligiblePosts.length === 0
|| recoveredEligiblePosts.length === 1
)
|| recoveredEligiblePosts.length === 1)
return false
const nextQuestion = chooseQuestion ({
@@ -4493,8 +4428,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
!(nextPlan.question)
&& !(shouldEnterGuessPhase (nextPlan.guessReason))
&& recovered.eligiblePosts.length !== 1
)
&& recovered.eligiblePosts.length !== 1)
{
const recoveredForQuestion = recoverQuestionState ({
nextAnswers,
@@ -4602,8 +4536,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
!(canPersistGame)
|| reviewGuessedPostId == null
|| reviewCorrectPostId == null
|| saveMutation.isPending
)
|| saveMutation.isPending)
return
if (savedGameId != null)
@@ -4666,8 +4599,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
!(canPersistGame)
|| savedGameId == null
|| extraQuestionAnswersMutation.isPending
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)]))
)
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])))
return
extraQuestionAnswersMutation.mutate ({
@@ -4870,8 +4802,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const answerExtraQuestion = (
questionId: number,
value: GekanatorAnswerValue,
) => {
value: GekanatorAnswerValue) => {
setExtraQuestionAnswers ({
...extraQuestionAnswers,
[String (questionId)]: value })
@@ -4909,8 +4840,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|| isLoading
|| acceptedQuestionsLoading
|| shouldEnterGuessPhase (questionPlan.guessReason)
|| eligiblePosts.length === 1
)
|| eligiblePosts.length === 1)
return
const recovered = recoverQuestionState ({
@@ -4926,8 +4856,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
recovered.recoveryStepCount === recoveryStepCount
&& recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size
&& recovered.softenedQuestionIds.size === softenedQuestionIds.size
)
&& recovered.softenedQuestionIds.size === softenedQuestionIds.size)
return
setSoftenedQuestionIds (recovered.softenedQuestionIds)
@@ -4954,15 +4883,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
phase !== 'question'
|| isLoading
|| acceptedQuestionsLoading
)
|| acceptedQuestionsLoading)
return
if (
currentQuestion
|| !(questionPlan.guess)
|| !(shouldEnterGuessPhase (questionPlan.guessReason))
)
|| !(shouldEnterGuessPhase (questionPlan.guessReason)))
return
setWinningRunTargetId (questionPlan.winningRunTargetId)
+2 -4
ファイルの表示
@@ -145,8 +145,7 @@ const DeerjikistDetailPage: FC = () => {
return rtn
})}/>)}
</FormField>
</fieldset>
))}
</fieldset>))}
<div className="py-3">
<button
@@ -169,8 +168,7 @@ const DeerjikistDetailPage: FC = () => {
</button>
</div>
</form>
</div>
)}
</div>)}
</MainArea>)
}
+4 -1
ファイルの表示
@@ -8,6 +8,7 @@ import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPatch: vi.fn (),
apiPut: vi.fn (),
}))
@@ -26,7 +27,7 @@ vi.mock ('@/components/ui/use-toast', () => toastApi)
const renderPage = () =>
renderWithProviders (
<Routes>
<Route path="/materials/:id" element={<MaterialDetailPage/>}/>
<Route path="/materials/:id" element={<MaterialDetailPage user={null}/>}/>
</Routes>,
{ route: '/materials/8' },
)
@@ -73,6 +74,7 @@ describe ('MaterialDetailPage', () => {
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'new' } })
fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } })
fireEvent.change (textboxes[2], { target: { value: '素材/new.png' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
await waitFor (() => {
@@ -81,6 +83,7 @@ describe ('MaterialDetailPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('new')
expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png')
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 { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
@@ -13,107 +14,149 @@ import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
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 { useValidationErrors } from '@/lib/useValidationErrors'
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'
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialDetailPage: FC = () => {
const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
const { id } = useParams ()
const qc = useQueryClient ()
const [exportPath, setExportPath] = useState ('')
const [file, setFile] = useState<File | null> (null)
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 [url, setURL] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData
if (tag.trim ())
formData.append ('tag', tag)
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)
}
}
const { data: material, isError, isLoading } = useQuery ({
queryKey: materialsKeys.show (id ?? ''),
queryFn: () => fetchMaterial (id ?? ''),
enabled: id != null})
const materialTitle = material
? material.tag?.name ?? `素材 #${ material.id }`
: ''
useEffect (() => {
if (!(id))
if (!(material))
return
void (async () => {
try
setTag (material.tag?.name ?? '')
setURL (material.url ?? '')
setExportPath (material.exportPaths.legacyDrive ?? '')
if (material.file && material.contentType)
{
setLoading (true)
const data = await apiGet<MaterialWithTag> (`/materials/${ id }`)
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 ?? '')
setFilePreview (material.file)
setFile (null)
}
finally
{
setLoading (false)
}
}) ()
}, [id])
}, [material])
const invalidateMaterialQueries = async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
}
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 (
<MainArea>
{material && (
<Helmet>
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
<title>{`${ materialTitle } 素材照会 | ${ SITE_TITLE }`}</title>
</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>
<TagLink
tag={material.tag}
withWiki={false}
withCount={false}/>
{material.tag
? (
<TagLink
tag={material.tag}
withWiki={false}
withCount={false}/>)
: materialTitle}
</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) && (
<img src={material.file} alt={material.tag.name || undefined}/>))
<img src={material.file} alt={material.tag?.name || undefined}/>))
|| (/video\/.*/.test (material.contentType) && (
<video src={material.file} controls/>))
|| (/audio\/.*/.test (material.contentType) && (
@@ -121,16 +164,21 @@ const MaterialDetailPage: FC = () => {
<TabGroup>
<Tab name="Wiki">
<WikiBody
title={material.tag.name}
body={material.wikiPageBody ?? undefined}/>
{material.tag
? (
<WikiBody
title={material.tag.name}
body={material.wikiPageBody ?? undefined}/>)
: (
<p className="text-stone-700 dark:text-stone-300">
</p>)}
</Tab>
<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}/>
{/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
@@ -140,7 +188,6 @@ const MaterialDetailPage: FC = () => {
setValue={setTag}/>)}
</FormField>
{/* ファイル */}
<FormField label="ファイル" messages={fieldErrors.file}>
{({ describedBy, invalid }) => (
<>
@@ -150,9 +197,10 @@ const MaterialDetailPage: FC = () => {
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
const nextFile = e.target.files?.[0]
setFile (nextFile ?? null)
setFilePreview (
nextFile ? URL.createObjectURL (nextFile) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
@@ -177,7 +225,6 @@ const MaterialDetailPage: FC = () => {
</>)}
</FormField>
{/* 参考 URL */}
<FormField label="参考 URL" messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input
@@ -189,17 +236,39 @@ const MaterialDetailPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
</Button>
<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>
<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>
</Tab>
</TabGroup>
</>))}
</>)}
</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 { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import nikumaru from '@/assets/fonts/nikumaru.otf'
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 SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { API_BASE_URL, SITE_TITLE } from '@/config'
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'> & {
children: TagWithMaterial[]
material: Material | null }
const MEDIA_KIND_LABELS: Record<Material['mediaKind'], string> = {
image: '画像',
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 }) => {
if (!(tag.material))
return
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 setIf = (qs: URLSearchParams, key: string, value: string | null) => {
const next = value?.trim ()
if (next)
qs.set (key, next)
}
const MaterialListPage: FC = () => {
const [loading, setLoading] = useState (false)
const [tag, setTag] = useState<TagWithMaterial | null> (null)
const parseOption = <T extends string> (
value: string | 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 query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
const navigate = useNavigate ()
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 (() => {
if (!(tagQuery))
{
setTag (null)
return
}
setQ (qQuery)
setTagStateInput (tagState)
setMediaKindInput (mediaKind)
setSuppressionInput (suppression)
setCreatedFrom (createdFromQuery)
setCreatedTo (createdToQuery)
setUpdatedFrom (updatedFromQuery)
setUpdatedTo (updatedToQuery)
}, [createdFromQuery, createdToQuery, mediaKind, qQuery, suppression, tagState,
updatedFromQuery, updatedToQuery])
void (async () => {
try
{
setLoading (true)
setTag (
await apiGet<TagWithMaterial> (
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
}
finally
{
setLoading (false)
}
}) ()
}, [location.search, tagQuery])
const search = (e: FormEvent) => {
e.preventDefault ()
const qs = new URLSearchParams ()
setIf (qs, 'q', q)
qs.set ('tag_state', tagStateInput)
qs.set ('media_kind', mediaKindInput)
qs.set ('suppression', suppressionInput)
setIf (qs, 'created_from', createdFrom)
setIf (qs, 'created_to', createdTo)
setIf (qs, 'updated_from', updatedFrom)
setIf (qs, 'updated_to', updatedTo)
qs.set ('sort', sort)
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 (
<MainArea>
@@ -82,86 +287,194 @@ const MaterialListPage: FC = () => {
src: url(${ nikumaru }) format('opentype');
}`}
</style>
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
<title>{`素材一覧 | ${ SITE_TITLE }`}</title>
</Helmet>
{loading ? 'Loading...' : (
tag
? (
<>
<PageTitle>
<TagLink
tag={tag}
withWiki={false}
withCount={false}
to={tag.material
? `/materials/${ tag.material.id }`
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/>
</PageTitle>
{(!(tag.material) && tag.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
</PrefetchLink>
</div>)}
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle>
<div className="flex flex-wrap gap-2">
<PrefetchLink
to="/materials/new"
className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm
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>
<a
href={`${ API_BASE_URL }/materials/download.zip?profile=legacy_drive`}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm
text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
ZIP
</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">
{tag.children.map (c2 => (
<Fragment key={c2.id}>
<SectionTitle>
<TagLink
tag={c2}
withWiki={false}
withCount={false}
to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/>
</SectionTitle>
{(!(c2.material) && c2.category !== 'meme') && (
<div className="-mt-4">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
</PrefetchLink>
</div>)}
<form
onSubmit={search}
className="max-w-3xl rounded-lg border border-stone-200 bg-white p-4
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-3">
<FormField label="検索">
{({ invalid }) => (
<input
type="search"
value={q}
onChange={e => setQ (e.target.value)}
placeholder="タグ名 / URL / ファイル名"
className={inputClass (invalid)}/>)}
</FormField>
<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">
{c2.children.map (c3 => (
<Fragment key={c3.id}>
<SubsectionTitle>
<TagLink
tag={c3}
withWiki={false}
withCount={false}
to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/>
</SubsectionTitle>
{(!(c3.material) && c3.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${
encodeURIComponent (c3.name) }`}>
</PrefetchLink>
</div>)}
<FormField label="メディア">
{({ invalid }) => (
<select
value={mediaKindInput}
onChange={e => setMediaKindInput (
e.target.value as MaterialIndexMediaKind)}
className={inputClass (invalid)}>
{Object.entries (MEDIA_FILTER_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
</option>))}
</select>)}
</FormField>
<MaterialCard tag={c3}/>
</Fragment>))}
</div>
</Fragment>))}
</div>
</>)
: (
<>
<p></p>
<p></p>
<ul>
<li><PrefetchLink to="/materials/new"></PrefetchLink></li>
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
</ul>
</>))}
<FormField label="抑止状態">
{({ invalid }) => (
<select
value={suppressionInput}
onChange={e => setSuppressionInput (
e.target.value as MaterialIndexSuppression)}
className={inputClass (invalid)}>
<option value="active"></option>
<option value="suppressed"></option>
<option value="all"></option>
</select>)}
</FormField>
<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>)
}
+46 -30
ファイルの表示
@@ -1,3 +1,4 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
@@ -11,16 +12,18 @@ import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
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 { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react'
type MaterialFormField = 'tag' | 'file' | 'url'
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialNewPage: FC = () => {
const qc = useQueryClient ()
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
@@ -29,39 +32,38 @@ const MaterialNewPage: FC = () => {
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('')
const [exportPath, setExportPath] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
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 ()
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)
}
createMutation.mutate ()
}
return (
@@ -133,11 +135,25 @@ const MaterialNewPage: FC = () => {
className={inputClass (invalid)}/>)}
</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
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
disabled={createMutation.isPending}>
</Button>
</Form>
+2 -4
ファイルの表示
@@ -62,8 +62,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
toast ({ title: '失敗……', description: '通信に失敗しました……' })
},
onSuccess: () => {
qc.invalidateQueries ({ queryKey: postsKeys.root })
} })
qc.invalidateQueries ({ queryKey: postsKeys.root })} })
useEffect (() => {
if (!(errorFlg))
@@ -114,8 +113,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
<PostList posts={[{ ...post, childPosts: [{ } as Post] },
...post.childPosts!.map (p => ({
...p, parentPosts: [{ } as Post] }))]}/>
</div>
)}
</div>)}
{(post.parentPosts ?? []).map (pp => {
const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`]
if (!(siblings))
+14 -20
ファイルの表示
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
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 { useLocation, useNavigate } from 'react-router-dom'
@@ -87,8 +87,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
const defaultDirection = {
name: 'asc',
created_at: 'desc',
updated_at: 'desc',
} as const
updated_at: 'desc'} as const
const beginEdit = async (tag: NicoTag) => {
const editingTag = nicoTags.find (tag => tag.id === editingId)
@@ -99,15 +98,13 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
&& !(await dialogue.confirm ({
title: '編集中の内容を破棄しますか?',
confirmText: '破棄',
variant: 'danger',
})))
variant: 'danger'})))
return
setEditingId (tag.id)
setRawTags (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]: [] }))
}
@@ -115,8 +112,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setEditingId (null)
setRawTags (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]: [] }))
}
@@ -141,8 +137,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
...errors,
[id]: validationError?.fieldErrors.tags
?? validationError?.baseErrors
?? ['更新できませんでした.'],
}))
?? ['更新できませんでした.']}))
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
}
finally
@@ -166,8 +161,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setRawTags (Object.fromEntries (data.tags.map (tag => [
tag.id,
tag.linkedTags.map (linkedTag => linkedTag.name).join (' '),
])))
tag.linkedTags.map (linkedTag => linkedTag.name).join (' ')])))
}, [data])
useEffect (() => {
@@ -282,7 +276,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
{nicoTags.map (tag => {
const isEditing = editingId === tag.id
return [
return (
<Fragment key={tag.id}>
<tr
key={tag.id}
className={cn (
@@ -321,8 +316,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
</button>)}
</td>)}
</tr>,
isEditing && (
</tr>
{isEditing && (
<tr key={`${ tag.id }-edit`}
className="border-b border-rose-200 bg-rose-50 dark:border-rose-900
dark:bg-rose-950/30">
@@ -340,8 +335,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
placeholder="タグ名を空白または改行で区切って入力"
onChange={e => setRawTags (rawTags => ({
...rawTags,
[tag.id]: e.target.value,
}))}/>
[tag.id]: e.target.value}))}/>
<FieldError messages={errorsByTagId[tag.id]}/>
<div className="flex justify-end gap-2">
<button
@@ -368,8 +362,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
</div>
</div>
</td>
</tr>),
]
</tr>)}
</Fragment>)
})}
</tbody>
</table>
+2 -4
ファイルの表示
@@ -69,8 +69,7 @@ const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
const commentBox = (
comment: TheatreComment,
programme: TheatreProgramme | null = null,
): ReactNode[] =>
programme: TheatreProgramme | null = null): ReactNode[] =>
[(
<div key={`${ comment.no }-content`} className="w-full">
{comment.deleted
@@ -120,8 +119,7 @@ const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{ tags, compact, flow = 'vertical' },
) => {
{ tags, compact, flow = 'vertical' }) => {
const grouped = tagsByCategory (tags)
if (flow === 'horizontal')
+1 -2
ファイルの表示
@@ -15,5 +15,4 @@ export const useSharedTransitionStore = create<SharedTransitionState> (set => ({
set (state => {
const next = { ...state.byLocationKey }
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 => ({
id: 1,
tag: buildTag (),
file: null,
url: null,
wikiPageBody: null,
contentType: null,
createdAt: '2026-01-02T03:04:05.000Z',
createdByUser: { id: 1, name: 'creator' },
updatedAt: '2026-01-03T03:04:05.000Z',
updatedByUser: { id: 2, name: 'updater' },
id: 1,
versionNo: 1,
tag: buildTag (),
file: null,
url: null,
wikiPageBody: null,
thumbnail: null,
thumbnailFallbackText: 'テストタグ',
thumbnailFallbackKind: 'tag_name',
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,
})
+83 -10
ファイルの表示
@@ -65,17 +65,90 @@ export type FetchNicoTagsOrder = `${ FetchNicoTagsOrderField }:${ 'asc' | 'desc'
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 = {
id: number
tag: Tag
file: string | null
url: string | null
wikiPageBody?: string | null
contentType: string | null
createdAt: string
createdByUser: { id: number; name: string }
updatedAt: string
updatedByUser: { id: number; name: string } }
id: number
versionNo: number
tag: Tag | null
file: string | null
url: string | null
wikiPageBody?: string | null
thumbnail: string | null
thumbnailFallbackText: string | null
thumbnailFallbackKind: 'tag_name' | 'created_at'
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[]