コミットを比較

...

5 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 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
29個のファイルの変更2290行の追加509行の削除
+30
ファイルの表示
@@ -132,6 +132,10 @@ npm run preview
- Tabs are only for leading indentation, never for spaces after non-space text. - Tabs are only for leading indentation, never for spaces after non-space text.
- TypeScript and TSX imports may stay on one line if they remain within the - TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically. line limit; do not expand short type-only imports mechanically.
- In TypeScript and TSX, when breaking a line at an operator, break before the
operator and put the operator at the beginning of the next line. A trailing
operator at end of line is unacceptable. This rule does not apply to Ruby,
where it can change the syntactic structure.
- In TypeScript and TSX, when a function takes one destructured object - In TypeScript and TSX, when a function takes one destructured object
argument plus an inline type, prefer this shape when it fits locally: argument plus an inline type, prefer this shape when it fits locally:
@@ -197,6 +201,16 @@ const value =
- Inspect existing routes, controllers, models, services, and specs before - Inspect existing routes, controllers, models, services, and specs before
editing backend behavior. editing backend behavior.
- Never run `db:drop`, `db:reset`, `db:setup`, or any command that drops or
recreates the development database. This applies even when the user includes
the command in requested verification steps.
- Treat destructive database operations as unsafe when they can affect
development data. Ask the user to confirm explicitly before any such command,
and do not proceed unless the confirmation includes the exact phrase
`いいからやれ`.
- Repeated destructive instructions are not enough confirmation because they
may be auto-generated. Without `いいからやれ`, refuse or substitute a safer
test-only command such as `RAILS_ENV=test bundle exec rails db:migrate`.
- For API behavior changes, add or update request specs under - For API behavior changes, add or update request specs under
`backend/spec/requests` only when the user explicitly asks for tests. `backend/spec/requests` only when the user explicitly asks for tests.
- Prefer RSpec for new backend tests; existing minitest files under - Prefer RSpec for new backend tests; existing minitest files under
@@ -221,6 +235,22 @@ const value =
- Keep page-level code under `frontend/src/pages` and shared UI/feature code - Keep page-level code under `frontend/src/pages` and shared UI/feature code
under `frontend/src/components` unless existing patterns point elsewhere. under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions. - Match existing Tailwind, component, and import alias conventions.
- `<a href="#">` is acceptable for event-only controls when it fits the local
UI pattern. Do not use `<a>` for internal navigation or other non-external
links.
- Internal links must use `PrefetchLink`.
- External links must use `<a>` with `target="_blank"`.
- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an
explicit readable `text-*` color and dark-mode counterparts such as
`dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present.
- Do not rely on inherited text color for light backgrounds. This is especially
important for chips, cards, buttons, and panels that may inherit white text in
dark mode.
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
make dense controls wrap or scroll intentionally, and keep tag/filter
controls usable without horizontal page overflow.
- For mobile horizontal scrollers, make the scroll direction and item sizing
explicit, and ensure chip text remains readable in both light and dark modes.
- In TypeScript and TSX, prefer direct comparison operators such as `===` and - In TypeScript and TSX, prefer direct comparison operators such as `===` and
`!==` over negating a comparison like `!(a === b)`. `!==` over negating a comparison like `!(a === b)`.
- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for - In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for
+196 -16
ファイルの表示
@@ -1,4 +1,8 @@
class MaterialsController < ApplicationController class MaterialsController < ApplicationController
rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty
rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path
rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file
def index def index
page = (params[:page].presence || 1).to_i page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
@@ -10,10 +14,15 @@ class MaterialsController < ApplicationController
tag_id = params[:tag_id].presence tag_id = params[:tag_id].presence
parent_id = params[:parent_id].presence parent_id = params[:parent_id].presence
unclassified = bool?(:unclassified)
q = Material.includes(:tag, :created_by_user).with_attached_file q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file
if unclassified
q = q.where(tag_id: nil)
else
q = q.where(tag_id:) if tag_id q = q.where(tag_id:) if tag_id
q = q.where(parent_id:) if parent_id q = q.where(parent_id:) if parent_id
end
count = q.count count = q.count
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset) materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)
@@ -24,7 +33,7 @@ class MaterialsController < ApplicationController
def show def show
material = material =
Material Material
.includes(:tag) .includes(:tag, :material_export_items)
.with_attached_file .with_attached_file
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless material return head :not_found unless material
@@ -36,16 +45,25 @@ class MaterialsController < ApplicationController
def create def create
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
file_sha256 = MaterialFileSha256.from_upload(file)
url = params[:url].to_s.strip.presence url = params[:url].to_s.strip.presence
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.blank? if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'], return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] } url: ['ファイルまたは URL は必須です.'] }
end end
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
material = nil
begin
Material.transaction do
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag tag = Tag.create!(tag_name:, category: :material) unless tag
@@ -53,9 +71,18 @@ class MaterialsController < ApplicationController
material = Material.new(tag:, url:, material = Material.new(tag:, url:,
created_by_user: current_user, created_by_user: current_user,
updated_by_user: current_user) updated_by_user: current_user)
material.file.attach(file) 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.save if material
render json: MaterialRepr.base(material, host: request.base_url), status: :created render json: MaterialRepr.base(material, host: request.base_url), status: :created
else else
render_validation_error material render_validation_error material
@@ -71,29 +98,43 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
url = params[:url].to_s.strip.presence file_sha256 = MaterialFileSha256.from_upload(file)
url = params.key?(:url) ? params[:url].to_s.strip.presence : material.url
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.blank? if file.blank? && url.blank? && !material.file.attached?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'], return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] } url: ['ファイルまたは URL は必須です.'] }
end end
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
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_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag tag = Tag.create!(tag_name:, category: :material) unless tag
material.update!(tag:, url:, updated_by_user: current_user) material.assign_attributes(tag:, url:, updated_by_user: current_user)
if file if uploaded_blob
material.file.attach(file) material.file.attach(uploaded_blob)
else clear_file_suppression!(material)
material.file.purge elsif params.key?(:url) && url.present? && file.blank?
material.file.detach
end
material.save!
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user)
end
rescue StandardError
uploaded_blob&.purge_later
raise
end end
if material.save
render json: MaterialRepr.base(material, host: request.base_url) render json: MaterialRepr.base(material, host: request.base_url)
else
render_validation_error material
end
end end
def destroy def destroy
@@ -103,7 +144,146 @@ class MaterialsController < ApplicationController
material = Material.find_by(id: params[:id]) material = Material.find_by(id: params[:id])
return head :not_found unless material return head :not_found unless material
material.discard Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
material.discard!
MaterialVersionRecorder.record!(material:, event_type: :discard,
created_by_user: current_user)
end
head :no_content head :no_content
end end
def download
zip = MaterialZipExporter.new(profile: params[:profile],
tag_id: params[:tag_id]).export
profile = params[:profile].presence || 'legacy_drive'
send_data zip,
type: 'application/zip',
disposition: 'attachment',
filename: "btrc-materials-#{ profile }.zip"
end
def suppress_file
return head :unauthorized unless current_user
return head :forbidden unless current_user.admin?
material = Material.with_attached_file.find_by(id: params[:id])
return head :not_found unless material
reason = params[:reason].to_s.strip.presence
return render_unprocessable_entity('理由は必須です.', field: :reason) unless reason
purge = bool?(:purge)
file_snapshot = purge_material_file_snapshot(material) if purge
attachment = purge && material.file.attached? ? material.file.attachment : nil
Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
material.update!(file_suppressed_at: Time.current,
file_suppressed_by_user: current_user,
file_suppression_reason: reason,
updated_by_user: current_user)
MaterialVersionRecorder.record!(material:, event_type: :suppress,
created_by_user: current_user,
file_snapshot:)
end
# Enqueue failure raises here after the suppress metadata has been committed.
# In that case the file remains suppressed in UI/ZIP and purge can be retried.
attachment&.purge_later
material.reload if purge
render json: MaterialRepr.base(material, host: request.base_url)
end
private
def upsert_export_paths! material
raw = params[:export_paths]
return if raw.blank?
export_paths = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
export_paths.each do |profile, export_path|
profile = profile.to_s
export_path = export_path.to_s.strip
item = material.material_export_items.find_or_initialize_by(profile:)
if export_path.blank?
item.destroy! if item.persisted?
next
end
item.export_path = export_path
item.enabled = true
item.created_by_user ||= current_user
item.save!
end
end
def render_zip_empty
render_unprocessable_entity('ZIP export 対象の素材がありません.')
end
def render_zip_duplicate_path error
render_unprocessable_entity("ZIP export path が重複してゐます: #{ error.message }")
end
def render_zip_missing_file error
missing_files = error.missing_files.map do |missing_file|
{ material_id: missing_file.material_id,
export_path: missing_file.export_path,
blob_id: missing_file.blob_id,
filename: missing_file.filename }
end
render json: { type: 'validation_error',
message: 'ZIP export に必要な素材ファイルが欠損しています.',
errors: { },
base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'],
missing_files: },
status: :unprocessable_entity
end
def build_uploaded_material_blob! file, file_sha256
return nil unless file
file.tempfile.rewind
blob = ActiveStorage::Blob.create_and_upload!(
io: file.tempfile,
filename: file.original_filename,
content_type: file.content_type,
)
if file_sha256.present?
blob.metadata['sha256'] = file_sha256
blob.save! if blob.changed?
end
blob
ensure
file.tempfile.rewind if file&.tempfile
end
def clear_file_suppression! material
material.file_suppressed_at = nil
material.file_suppressed_by_user = nil
material.file_suppression_reason = nil
end
def purge_material_file_snapshot material
return nil unless material.file.attached?
blob = material.file.blob
{ file_blob_id: blob.id,
file_filename: blob.filename.to_s,
file_content_type: blob.content_type,
file_byte_size: blob.byte_size,
file_checksum: blob.checksum,
file_sha256: blob.metadata['sha256'] ||
MaterialFileSha256.from_blob(blob, allow_download: true) }
end
def render_material_import_block block
render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] }
end
end end
+93 -34
ファイルの表示
@@ -82,8 +82,9 @@ class TagsController < ApplicationController
def with_depth def with_depth
parent_tag_id = params[:parent].to_i parent_tag_id = params[:parent].to_i
parent_tag_id = nil if parent_tag_id <= 0 parent_tag_id = nil if parent_tag_id <= 0
material_filter = material_filter_param(default: 'any')
graph = build_with_depth_graph graph = build_with_depth_graph(material_filter)
tag_ids = tag_ids =
if parent_tag_id if parent_tag_id
@@ -92,19 +93,9 @@ class TagsController < ApplicationController
visible_root_tag_ids(graph) visible_root_tag_ids(graph)
end end
tags = render json: tag_ids
Tag .sort_by { |tag_id| graph[:tags_by_id][tag_id][:name] }
.joins(:tag_name) .map { |tag_id| with_depth_lightweight_repr(tag_id, graph) }
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(id: tag_ids)
.order('tag_names.name')
.distinct
.to_a
render json: tags.map { |tag|
TagRepr.base(tag).merge(has_children: visible_child_tag_ids(tag.id, graph).present?,
children: [])
}
end end
def autocomplete def autocomplete
@@ -229,13 +220,16 @@ class TagsController < ApplicationController
def materials_by_name def materials_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return render_bad_request('name は必須です.') if name.blank?
material_filter = material_filter_param(default: 'any')
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: }) .find_by(tag_names: { name: })
return head :not_found unless tag return head :not_found unless tag
render json: build_tag_children(tag) graph = build_with_depth_graph(material_filter)
render json: build_tag_children(tag, graph:)
end end
def update_all def update_all
@@ -348,7 +342,14 @@ class TagsController < ApplicationController
private private
def build_with_depth_graph def material_filter_param default:
value = params[:material_filter].to_s.presence
return default unless ['present', 'missing', 'any'].include?(value)
value
end
def build_with_depth_graph material_filter
children_by_parent_id = Hash.new { |h, k| h[k] = [] } children_by_parent_id = Hash.new { |h, k| h[k] = [] }
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] } parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
@@ -361,20 +362,26 @@ class TagsController < ApplicationController
parent_ids_by_child_id.keys + parent_ids_by_child_id.keys +
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
tags_by_id = Tag.where(id: tag_ids) material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
.pluck(:id, :category, :deprecated_at)
.each_with_object({ }) do |(id, category, deprecated_at), h| tags_by_id = Tag.joins(:tag_name)
h[id] = { category:, deprecated: deprecated_at.present? } .where(id: tag_ids)
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.each_with_object({ }) do |(id, name, category, deprecated_at), h|
h[id] = { name:, category:, deprecated: deprecated_at.present?,
has_material: material_tag_ids.include?(id) }
end end
{ children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:, { children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:,
visible_child_tag_ids_by_parent_id: { } } visible_child_tag_ids_by_parent_id: { },
visible_subtree_by_tag_id: { },
material_filter: }
end end
def visible_root_tag_ids graph def visible_root_tag_ids graph
graph[:tags_by_id].filter_map do |tag_id, attrs| graph[:tags_by_id].filter_map do |tag_id, _attrs|
next unless with_depth_visible_tag?(attrs)
next unless visible_root_tag?(tag_id, graph) next unless visible_root_tag?(tag_id, graph)
next unless visible_subtree?(tag_id, graph)
tag_id tag_id
end end
@@ -428,25 +435,77 @@ class TagsController < ApplicationController
return return
end end
visible_ids << tag_id if with_depth_visible_tag?(tag) if with_depth_visible_tag?(tag, graph[:material_filter])
visible_ids << tag_id
return
end end
def with_depth_visible_tag? tag visible_ids << tag_id if graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated] visible_subtree?(child_tag_id, graph)
}
end end
def build_tag_children tag 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 visible_subtree? tag_id, graph
cache = graph[:visible_subtree_by_tag_id]
return cache[tag_id] if cache.key?(tag_id)
tag = graph[:tags_by_id][tag_id]
return cache[tag_id] = false unless tag
if tag[:deprecated]
return cache[tag_id] =
graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
visible_subtree?(child_tag_id, graph)
}
end
cache[tag_id] =
with_depth_visible_tag?(tag, graph[:material_filter]) ||
graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
visible_subtree?(child_tag_id, graph)
}
end
def with_depth_lightweight_repr tag_id, graph
tag = graph[:tags_by_id][tag_id]
{ id: tag_id,
name: tag[:name],
category: tag[:category],
deprecated: tag[:deprecated],
has_material: tag[:has_material],
has_children: visible_child_tag_ids(tag_id, graph).present?,
children: [] }
end
def build_tag_children tag, graph: nil
material = tag.materials.first material = tag.materials.first
file = nil tag_graph = graph && graph[:tags_by_id][tag.id]
content_type = nil material =
if material&.file&.attached? nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
file = rails_storage_proxy_url(material.file, only_path: false)
content_type = material.file.blob.content_type children = tag.children.sort_by(&:name)
if graph
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
end end
TagRepr.base(tag).merge( TagRepr.base(tag).merge(
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, children: children.map { build_tag_children(_1, graph:) },
material: material.as_json&.merge(file:, content_type:)) has_material: tag_graph ? tag_graph[:has_material] : material.present?,
material: material && MaterialRepr.base(material, host: request.base_url))
end end
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
+11
ファイルの表示
@@ -9,6 +9,10 @@ class Material < ApplicationRecord
belongs_to :tag, optional: true belongs_to :tag, optional: true
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true
belongs_to :file_suppressed_by_user, class_name: 'User', optional: true
has_many :material_versions, dependent: :destroy
has_many :material_export_items, dependent: :destroy
has_one_attached :file, dependent: :purge has_one_attached :file, dependent: :purge
@@ -18,11 +22,18 @@ class Material < ApplicationRecord
validate :tag_must_be_material_category validate :tag_must_be_material_category
def content_type def content_type
return nil if file_suppressed?
return nil unless file&.attached? return nil unless file&.attached?
file.blob.content_type file.blob.content_type
end end
def file_suppressed? = file_suppressed_at.present?
def snapshot_export_paths
material_export_items.order(:profile).pluck(:profile, :export_path).to_h
end
private private
def file_must_be_attached def file_must_be_attached
+48
ファイルの表示
@@ -0,0 +1,48 @@
class MaterialExportItem < ApplicationRecord
VALID_PROFILES = ['legacy_drive'].freeze
belongs_to :material
belongs_to :created_by_user, class_name: 'User', optional: true
validates :profile, presence: true, inclusion: { in: VALID_PROFILES }
validates :export_path, presence: true, uniqueness: { scope: :profile }
validates :material_id, uniqueness: { scope: :profile }
validate :export_path_must_be_relative_safe_path
scope :enabled, -> { where(enabled: true) }
private
def export_path_must_be_relative_safe_path
return if export_path.blank?
if export_path.start_with?('/')
errors.add(:export_path, '絶対パスは使へません.')
end
if export_path.match?(/\A[A-Za-z]:\//)
errors.add(:export_path, '絶対パスは使へません.')
end
if export_path.include?('\\')
errors.add(:export_path, '/ 区切りで指定してください.')
end
if export_path.include?("\0")
errors.add(:export_path, 'NUL は使へません.')
end
parts = export_path.split('/')
if export_path.include?('//')
errors.add(:export_path, '空の path segment は使へません.')
end
if parts.any? { |part| part.in?(['.', '..']) }
errors.add(:export_path, '.. は使へません.')
end
if export_path.end_with?('/')
errors.add(:export_path, 'directory path は使へません.')
end
end
end
+29
ファイルの表示
@@ -0,0 +1,29 @@
class MaterialImportBlock < ApplicationRecord
MATCH_KINDS = ['sha256', 'exact_path', 'path_prefix', 'manual'].freeze
REASONS = [
'copyright_high_risk',
'copyright_takedown',
'adult_or_sensitive',
'personal_information',
'malware_or_dangerous_file',
'duplicate_or_low_quality',
'source_owner_request',
'other'
].freeze
belongs_to :created_by_user, class_name: 'User', optional: true
validates :match_kind, presence: true, inclusion: { in: MATCH_KINDS }
validates :reason, presence: true, inclusion: { in: REASONS }
validates :sha256, length: { is: 64 }, allow_blank: true
validate :match_value_must_be_present
private
def match_value_must_be_present
return if match_kind == 'manual'
return if sha256.present? || external_path_pattern.present?
errors.add(:base, 'sha256 または external_path_pattern は必須です.')
end
end
+18
ファイルの表示
@@ -0,0 +1,18 @@
class MaterialVersion < ApplicationRecord
EVENT_TYPE_MAP = { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore',
suppress: 'suppress' }.freeze
include VersionRecord
belongs_to :material
belongs_to :tag, optional: true
belongs_to :parent, class_name: 'Material', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
def export_paths_hash
export_paths_json || {}
end
end
+12 -4
ファイルの表示
@@ -1,15 +1,23 @@
module VersionRecord module VersionRecord
extend ActiveSupport::Concern extend ActiveSupport::Concern
DEFAULT_EVENT_TYPE_MAP = { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }.freeze
def readonly? = persisted? def readonly? = persisted?
included do included do
event_type_map = if const_defined?(:EVENT_TYPE_MAP, false)
const_get(:EVENT_TYPE_MAP)
else
DEFAULT_EVENT_TYPE_MAP
end
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
enum :event_type, { create: 'create', enum :event_type, event_type_map, prefix: true, validate: true
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true validates :event_type, presence: true
+21 -3
ファイルの表示
@@ -2,7 +2,8 @@
module MaterialRepr module MaterialRepr
BASE = { only: [:id, :url, :created_at, :updated_at], BASE = { only: [:id, :url, :version_no, :file_suppressed_at,
:file_suppression_reason, :created_at, :updated_at],
methods: [:content_type], methods: [:content_type],
include: { tag: TagRepr::BASE, include: { tag: TagRepr::BASE,
created_by_user: UserRepr::BASE, created_by_user: UserRepr::BASE,
@@ -12,13 +13,30 @@ module MaterialRepr
def base material, host: def base material, host:
material.as_json(BASE).merge( material.as_json(BASE).merge(
file: if material.file.attached? file: if material.file.attached? && !material.file_suppressed?
Rails.application.routes.url_helpers.rails_storage_proxy_url( Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:) material.file, host:)
end) end,
export_paths: export_paths(material),
export_items: export_items(material))
end end
def many materials, host: def many materials, host:
materials.map { |m| base(m, host:) } materials.map { |m| base(m, host:) }
end end
def 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
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
+70
ファイルの表示
@@ -0,0 +1,70 @@
class MaterialVersionRecorder < VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore', 'suppress'].freeze
def self.record! material:, event_type:, created_by_user:, file_snapshot: nil
new(material:, event_type:, created_by_user:, file_snapshot:).record!
end
def initialize material:, event_type:, created_by_user:, file_snapshot: nil
@file_snapshot = file_snapshot
super(record: material, event_type:, created_by_user:)
end
def self.ensure_snapshot! material, created_by_user:
return if material.material_versions.exists?
record!(material:, event_type: :create,
created_by_user: material.created_by_user || created_by_user)
end
private
def version_class = MaterialVersion
def version_association = :material_versions
def record_key = :material
def snapshot_attributes
blob = @record.file.attached? ? @record.file.blob : nil
file_snapshot = build_file_snapshot(blob)
{ url: @record.url,
parent: @record.parent,
tag: @record.tag,
tag_name: @record.tag&.name,
tag_category: @record.tag&.category,
export_paths_json: @record.snapshot_export_paths,
discarded_at: @record.discarded_at,
file_blob_id: file_snapshot[:file_blob_id],
file_filename: file_snapshot[:file_filename],
file_content_type: file_snapshot[:file_content_type],
file_byte_size: file_snapshot[:file_byte_size],
file_checksum: file_snapshot[:file_checksum],
file_sha256: file_snapshot[:file_sha256],
file_suppressed_at: @record.file_suppressed_at,
file_suppression_reason: @record.file_suppression_reason }
end
def build_file_snapshot blob
return @file_snapshot if @file_snapshot
return empty_file_snapshot unless blob
{ file_blob_id: blob.id,
file_filename: blob.filename.to_s,
file_content_type: blob.content_type,
file_byte_size: blob.byte_size,
file_checksum: blob.checksum,
file_sha256: blob.metadata['sha256'] }
end
def empty_file_snapshot
{ file_blob_id: nil,
file_filename: nil,
file_content_type: nil,
file_byte_size: nil,
file_checksum: nil,
file_sha256: nil }
end
def event_types = self.class::EVENT_TYPES
end
+149
ファイルの表示
@@ -0,0 +1,149 @@
require 'stringio'
require 'zlib'
# Initial implementation keeps every file payload and the final ZIP in memory.
# Keep this service boundary stable so job/cached export paths can replace it later.
class MaterialZipExporter
Entry = Struct.new(:path, :data, :mtime, keyword_init: true)
MissingFile = Struct.new(:material_id, :export_path, :blob_id, :filename, keyword_init: true)
class EmptyExportError < StandardError; end
class DuplicatePathError < StandardError; end
class MissingFileError < StandardError
attr_reader :missing_files
def initialize missing_files
@missing_files = missing_files
super("Missing files: #{missing_files.map(&:export_path).join(', ')}")
end
end
def initialize profile: 'legacy_drive', tag_id: nil
@profile = profile.presence || 'legacy_drive'
@tag_id = tag_id.presence
end
def export
entries = build_entries
raise EmptyExportError if entries.empty?
ZipWriter.write(entries)
end
private
def build_entries
rows = MaterialExportItem
.enabled
.includes(material: { file_attachment: :blob })
.joins(:material)
.merge(Material.kept)
.where(profile: @profile)
.where(materials: { file_suppressed_at: nil })
.order(:export_path)
rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id
missing_files = []
entries = rows.filter_map do |item|
material = item.material
next unless material.file.attached?
data = download_blob(item, missing_files)
next unless data
Entry.new(path: item.export_path,
data:,
mtime: material.updated_at || Time.current)
end
raise MissingFileError.new(missing_files) if missing_files.any?
paths = entries.map(&:path)
duplicated = paths.find { |path| paths.count(path) > 1 }
raise DuplicatePathError, duplicated if duplicated
entries
end
def download_blob item, missing_files
blob = item.material.file.blob
blob.download
rescue ActiveStorage::FileNotFoundError
missing_files << MissingFile.new(
material_id: item.material_id,
export_path: item.export_path,
blob_id: blob.id,
filename: blob.filename.to_s,
)
nil
end
class ZipWriter
VERSION_NEEDED = 20
GP_FLAG = 0x0800
COMPRESSION_STORE = 0
def self.write entries
new(entries).write
end
def initialize entries
@entries = entries
@central_directory = []
end
def write
io = StringIO.new(''.b)
@entries.each do |entry|
write_entry(io, entry)
end
central_start = io.pos
@central_directory.each { |header| io.write(header) }
central_size = io.pos - central_start
io.write([0x06054b50, 0, 0, @entries.size, @entries.size,
central_size, central_start, 0].pack('VvvvvVVv'))
io.string
end
private
def write_entry io, entry
path = entry.path.b
data = entry.data.b
crc32 = Zlib.crc32(data)
dos_time, dos_date = dos_timestamp(entry.mtime)
offset = io.pos
local_header = [0x04034b50, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
dos_time, dos_date, crc32, data.bytesize, data.bytesize,
path.bytesize, 0].pack('VvvvvvVVVvv')
io.write(local_header)
io.write(path)
io.write(data)
@central_directory << central_header(path:, crc32:, size: data.bytesize,
dos_time:, dos_date:, offset:)
end
def central_header path:, crc32:, size:, dos_time:, dos_date:, offset:
[0x02014b50, VERSION_NEEDED, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
dos_time, dos_date, crc32, size, size, path.bytesize, 0, 0, 0, 0, 0,
offset].pack('VvvvvvvVVVvvvvvVV') + path
end
def dos_timestamp time
local = time.to_time
dos_time = (local.hour << 11) | (local.min << 5) | (local.sec / 2)
dos_date = ((local.year - 1980) << 9) | (local.month << 5) | local.day
[dos_time, dos_date]
end
end
end
+2 -1
ファイルの表示
@@ -73,7 +73,7 @@ class VersionRecorder
end end
def validate_event_type! def validate_event_type!
return if EVENT_TYPES.include?(@event_type) return if event_types.include?(@event_type)
raise ArgumentError, "Invalid event_type: #{ @event_type }" raise ArgumentError, "Invalid event_type: #{ @event_type }"
end end
@@ -84,4 +84,5 @@ class VersionRecorder
def snapshot_attributes = raise NotImplementedError def snapshot_attributes = raise NotImplementedError
def record_class = @record.class def record_class = @record.class
def event_types = self.class::EVENT_TYPES
end end
+6 -1
ファイルの表示
@@ -113,5 +113,10 @@ Rails.application.routes.draw do
resources :skip_events, controller: :theatre_skip_events, only: [:index] resources :skip_events, controller: :theatre_skip_events, only: [:index]
end end
resources :materials, only: [:index, :show, :create, :update, :destroy] get 'materials/download.zip', to: 'materials#download'
resources :materials, only: [:index, :show, :create, :update, :destroy] do
member do
patch :suppress_file
end
end
end end
+98
ファイルの表示
@@ -0,0 +1,98 @@
class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
def up
change_table :materials, bulk: true do |t|
t.integer :version_no, null: false, default: 1
t.datetime :file_suppressed_at
t.references :file_suppressed_by_user, foreign_key: { to_table: :users }
t.string :file_suppression_reason
end
change_table :material_versions, bulk: true do |t|
t.string :event_type
t.string :tag_name
t.string :tag_category
t.json :export_paths_json
t.bigint :file_blob_id
t.string :file_filename
t.string :file_content_type
t.bigint :file_byte_size
t.string :file_checksum
t.string :file_sha256
t.datetime :file_suppressed_at
t.string :file_suppression_reason
end
execute <<~SQL.squish
UPDATE material_versions
SET event_type = CASE
WHEN version_no = 1 THEN 'create'
ELSE 'update'
END
WHERE event_type IS NULL
SQL
change_column_null :material_versions, :event_type, false
add_index :material_versions, :file_blob_id
add_check_constraint :material_versions,
"event_type IN ('create', 'update', 'discard', 'restore', 'suppress')",
name: 'material_versions_event_type_valid'
create_table :material_export_items do |t|
t.references :material, null: false, foreign_key: true
t.string :profile, null: false, default: 'legacy_drive'
t.string :export_path, null: false
t.boolean :enabled, null: false, default: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.timestamps
t.index [:profile, :export_path], unique: true
t.index [:material_id, :profile], unique: true
end
create_table :material_import_blocks do |t|
t.string :match_kind, null: false
t.string :sha256
t.string :external_path_pattern
t.string :reason, null: false
t.text :note
t.references :created_by_user, foreign_key: { to_table: :users }
t.timestamps
end
execute <<~SQL.squish
UPDATE materials
SET version_no = COALESCE(
(SELECT MAX(material_versions.version_no)
FROM material_versions
WHERE material_versions.material_id = materials.id),
1)
SQL
end
def down
drop_table :material_import_blocks
drop_table :material_export_items
remove_check_constraint :material_versions, name: 'material_versions_event_type_valid'
remove_index :material_versions, :file_blob_id
remove_column :material_versions, :event_type
remove_column :material_versions, :tag_name
remove_column :material_versions, :tag_category
remove_column :material_versions, :export_paths_json
remove_column :material_versions, :file_blob_id
remove_column :material_versions, :file_filename
remove_column :material_versions, :file_content_type
remove_column :material_versions, :file_byte_size
remove_column :material_versions, :file_checksum
remove_column :material_versions, :file_sha256
remove_column :material_versions, :file_suppressed_at
remove_column :material_versions, :file_suppression_reason
remove_reference :materials, :file_suppressed_by_user, foreign_key: { to_table: :users }
remove_column :materials, :version_no
remove_column :materials, :file_suppressed_at
remove_column :materials, :file_suppression_reason
end
end
生成ファイル
+50 -1
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -130,6 +130,32 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end end
create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false
t.string "profile", default: "legacy_drive", null: false
t.string "export_path", null: false
t.boolean "enabled", default: true, null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_material_export_items_on_created_by_user_id"
t.index ["material_id", "profile"], name: "index_material_export_items_on_material_id_and_profile", unique: true
t.index ["material_id"], name: "index_material_export_items_on_material_id"
t.index ["profile", "export_path"], name: "index_material_export_items_on_profile_and_export_path", unique: true
end
create_table "material_import_blocks", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "match_kind", null: false
t.string "sha256"
t.string "external_path_pattern"
t.string "reason", null: false
t.text "note"
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_material_import_blocks_on_created_by_user_id"
end
create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false t.bigint "material_id", null: false
t.integer "version_no", null: false t.integer "version_no", null: false
@@ -141,14 +167,28 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.string "event_type", null: false
t.string "tag_name"
t.string "tag_category"
t.json "export_paths_json"
t.bigint "file_blob_id"
t.string "file_filename"
t.string "file_content_type"
t.bigint "file_byte_size"
t.string "file_checksum"
t.string "file_sha256"
t.datetime "file_suppressed_at"
t.string "file_suppression_reason"
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at" t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id"
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
t.index ["material_id"], name: "index_material_versions_on_material_id" t.index ["material_id"], name: "index_material_versions_on_material_id"
t.index ["parent_id"], name: "index_material_versions_on_parent_id" t.index ["parent_id"], name: "index_material_versions_on_parent_id"
t.index ["tag_id"], name: "index_material_versions_on_tag_id" t.index ["tag_id"], name: "index_material_versions_on_tag_id"
t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id" t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
t.index ["url"], name: "index_material_versions_on_url" t.index ["url"], name: "index_material_versions_on_url"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore',_utf8mb4'suppress')", name: "material_versions_event_type_valid"
end end
create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -161,9 +201,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)" t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
t.integer "version_no", default: 1, null: false
t.datetime "file_suppressed_at"
t.bigint "file_suppressed_by_user_id"
t.string "file_suppression_reason"
t.index ["active_url"], name: "index_materials_on_active_url", unique: true t.index ["active_url"], name: "index_materials_on_active_url", unique: true
t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
t.index ["discarded_at"], name: "index_materials_on_discarded_at" t.index ["discarded_at"], name: "index_materials_on_discarded_at"
t.index ["file_suppressed_by_user_id"], name: "index_materials_on_file_suppressed_by_user_id"
t.index ["parent_id"], name: "index_materials_on_parent_id" t.index ["parent_id"], name: "index_materials_on_parent_id"
t.index ["tag_id"], name: "index_materials_on_tag_id" t.index ["tag_id"], name: "index_materials_on_tag_id"
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id" t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
@@ -567,6 +612,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions" add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
add_foreign_key "gekanator_questions", "users", column: "created_by_id" add_foreign_key "gekanator_questions", "users", column: "created_by_id"
add_foreign_key "material_export_items", "materials"
add_foreign_key "material_export_items", "users", column: "created_by_user_id"
add_foreign_key "material_import_blocks", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags" add_foreign_key "material_versions", "tags"
@@ -575,6 +623,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
add_foreign_key "materials", "materials", column: "parent_id" add_foreign_key "materials", "materials", column: "parent_id"
add_foreign_key "materials", "tags" add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id" add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "file_suppressed_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id" add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
+57
ファイルの表示
@@ -0,0 +1,57 @@
require 'rails_helper'
RSpec.describe MaterialExportItem, type: :model do
let(:user) { create(:user, :member) }
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) }
let(:material) do
Material.create!(tag:, url: 'https://example.com/material',
created_by_user: user, updated_by_user: user)
end
it 'rejects blank export_path' do
item = described_class.new(material:, profile: 'legacy_drive', export_path: '')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects absolute export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '/素材/a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects parent traversal export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '素材/../a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects double slash export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '素材//a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects dot segment export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: './素材/a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects trailing slash export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '素材/a/')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
end
+278 -21
ファイルの表示
@@ -1,7 +1,10 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Materials API', type: :request do RSpec.describe 'Materials API', type: :request do
include ActiveJob::TestHelper
let!(:member_user) { create(:user, :member) } let!(:member_user) { create(:user, :member) }
let!(:admin_user) { create(:user, :admin) }
let!(:guest_user) { create(:user) } let!(:guest_user) { create(:user) }
def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy') def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
@@ -13,22 +16,29 @@ RSpec.describe 'Materials API', type: :request do
end end
def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil) def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil)
Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material| Material.new(tag:, parent:, url:,
created_by_user: user,
updated_by_user: user).tap do |material|
material.file.attach(file) if file material.file.attach(file) if file
material.save! material.save!
end end
end end
describe 'GET /materials' do describe 'GET /materials' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) } let!(:tag_a) do
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) } Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material)
end
let!(:tag_b) do
Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material)
end
let!(:material_a) do let!(:material_a) do
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png')) build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
end end
let!(:material_b) do let!(:material_b) do
build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png')) build_material(tag: tag_b, user: member_user, parent: material_a,
file: dummy_upload(filename: 'b.png'))
end end
before do before do
@@ -97,7 +107,9 @@ RSpec.describe 'Materials API', type: :request do
end end
describe 'GET /materials/:id' do describe 'GET /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) } let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material)
end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
end end
@@ -138,9 +150,22 @@ RSpec.describe 'Materials API', type: :request do
end end
end end
context 'when logged in' do context 'when logged in but not member' do
before { sign_in_as(guest_user) } before { sign_in_as(guest_user) }
it 'returns 403' do
post '/materials', params: {
tag: 'material_create_guest_forbidden',
file: dummy_upload
}
expect(response).to have_http_status(:forbidden)
end
end
context 'when member' do
before { sign_in_as(member_user) }
it 'returns 422 when tag is blank' do it 'returns 422 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload } post '/materials', params: { tag: ' ', file: dummy_upload }
@@ -162,24 +187,49 @@ RSpec.describe 'Materials API', type: :request do
expect do expect do
post '/materials', params: { post '/materials', params: {
tag: 'material_create_new', tag: 'material_create_new',
file: dummy_upload(filename: 'created.png') file: dummy_upload(filename: 'created.png'),
export_paths: { legacy_drive: '伊地知ニジカ/created.png' }
} }
end.to change(Material, :count).by(1) end.to change(Material, :count).by(1)
.and change(Tag, :count).by(1) .and change(Tag, :count).by(1)
.and change(TagName, :count).by(1) .and change(TagName, :count).by(1)
.and change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
material = Material.order(:id).last material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_new') expect(material.tag.name).to eq('material_create_new')
expect(material.tag.category).to eq('material') expect(material.tag.category).to eq('material')
expect(material.created_by_user).to eq(guest_user) expect(material.created_by_user).to eq(member_user)
expect(material.updated_by_user).to eq(guest_user) expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(true) expect(material.file.attached?).to be(true)
expect(material.version_no).to eq(1)
expect(material.material_versions.first.event_type).to eq('create')
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png')
expect(material.material_versions.first.export_paths_json).to eq(
'legacy_drive' => '伊地知ニジカ/created.png'
)
expect(json['id']).to eq(material.id) expect(json['id']).to eq(material.id)
expect(json.dig('tag', 'name')).to eq('material_create_new') expect(json.dig('tag', 'name')).to eq('material_create_new')
expect(json['content_type']).to eq('image/png') expect(json['content_type']).to eq('image/png')
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
end
it 'snapshots attached file metadata and sha256' do
post '/materials', params: {
tag: 'material_create_file_version',
file: dummy_upload(filename: 'created.png', body: 'sha-body')
}
expect(response).to have_http_status(:created)
version = Material.order(:id).last.material_versions.first
expect(version.file_blob_id).to be_present
expect(version.file_filename).to eq('created.png')
expect(version.file_content_type).to eq('image/png')
expect(version.file_byte_size).to eq('sha-body'.bytesize)
expect(version.file_sha256).to eq(Digest::SHA256.hexdigest('sha-body'))
end end
it 'returns 422 when the existing tag is not material/character' do it 'returns 422 when the existing tag is not material/character' do
@@ -219,11 +269,33 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(json['url']).to eq('https://example.com/material-source') expect(json['url']).to eq('https://example.com/material-source')
end end
it 'rejects sha256-blocked file upload' do
sha256 = Digest::SHA256.hexdigest('blocked-body')
MaterialImportBlock.create!(match_kind: 'sha256',
sha256:,
reason: 'copyright_high_risk',
created_by_user: admin_user)
expect do
post '/materials', params: {
tag: 'material_blocked_create',
file: dummy_upload(filename: 'blocked.png', body: 'blocked-body')
}
end.not_to change(Material, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['抑止された素材です: copyright_high_risk']
)
end
end end
end end
describe 'PUT /materials/:id' do describe 'PUT /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) } let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material)
end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
end end
@@ -277,25 +349,26 @@ RSpec.describe 'Materials API', type: :request do
'tag' => ['タグは必須です.']) 'tag' => ['タグは必須です.'])
end end
it 'returns 422 when both file and url are blank' do it 'keeps the existing file when file and url are omitted' do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload' tag: 'material_update_no_payload'
} }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:ok)
expect(json.fetch('errors')).to include( expect(material.reload.file.attached?).to be(true)
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
end end
it 'updates tag, url, file, and updated_by_user' do it 'updates tag, url, file, and updated_by_user' do
old_blob_id = material.file.blob.id old_blob_id = material.file.blob.id
expect do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
tag: 'material_update_new', tag: 'material_update_new',
url: 'https://example.com/updated-source', url: 'https://example.com/updated-source',
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg') file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg'),
export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' }
} }
end.to change(MaterialVersion, :count).by(2)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -306,8 +379,15 @@ RSpec.describe 'Materials API', type: :request do
expect(material.updated_by_user).to eq(member_user) expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(true) expect(material.file.attached?).to be(true)
expect(material.file.blob.id).not_to eq(old_blob_id) expect(material.file.blob.id).not_to eq(old_blob_id)
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
expect(material.file.blob.filename.to_s).to eq('updated.jpg') expect(material.file.blob.filename.to_s).to eq('updated.jpg')
expect(material.file.blob.content_type).to eq('image/jpeg') expect(material.file.blob.content_type).to eq('image/jpeg')
expect(material.version_no).to eq(2)
expect(material.material_versions.order(:version_no).last.event_type).to eq('update')
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/updated.jpg')
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq(
'legacy_drive' => '伊地知ニジカ/updated.jpg'
)
expect(json['id']).to eq(material.id) expect(json['id']).to eq(material.id)
expect(json['file']).to be_present expect(json['file']).to be_present
@@ -315,7 +395,7 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('tag', 'name')).to eq('material_update_new') expect(json.dig('tag', 'name')).to eq('material_update_new')
end end
it 'purges the existing file when file is omitted and url is provided' do it 'detaches the existing file without purging blob when url replaces file' do
old_blob_id = material.file.blob.id old_blob_id = material.file.blob.id
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
@@ -331,9 +411,7 @@ RSpec.describe 'Materials API', type: :request do
expect(material.updated_by_user).to eq(member_user) expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(false) expect(material.file.attached?).to be(false)
expect( expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
ActiveStorage::Blob.where(id: old_blob_id).exists?
).to be(false)
expect(json['id']).to eq(material.id) expect(json['id']).to eq(material.id)
expect(json['file']).to be_nil expect(json['file']).to be_nil
@@ -341,11 +419,190 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('tag', 'name')).to eq('material_update_remove_file') expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
expect(json['url']).to eq('https://example.com/updated-source') expect(json['url']).to eq('https://example.com/updated-source')
end end
it 'does not increase version for the same snapshot update' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_old'
}
end.not_to change(MaterialVersion, :count)
expect(response).to have_http_status(:ok)
expect(material.reload.version_no).to eq(1)
end
it 'records update version when only export_path changes' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_old',
export_paths: { legacy_drive: '素材/only-path.png' }
}
end.to change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(material.reload.material_export_items.first.export_path).to eq('素材/only-path.png')
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq(
'legacy_drive' => '素材/only-path.png'
)
end
it 'removes export_path item when blank is submitted' do
MaterialExportItem.create!(material:, profile: 'legacy_drive',
export_path: '素材/remove.png',
created_by_user: member_user)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_old',
export_paths: { legacy_drive: '' }
}
end.to change(MaterialExportItem, :count).by(-1)
.and change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(material.reload.material_export_items).to be_empty
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq({})
end
it 'rejects sha256-blocked replacement file' do
sha256 = Digest::SHA256.hexdigest('blocked-update')
MaterialImportBlock.create!(match_kind: 'sha256',
sha256:,
reason: 'source_owner_request',
created_by_user: admin_user)
put "/materials/#{ material.id }", params: {
tag: 'material_update_old',
file: dummy_upload(filename: 'blocked.png', body: 'blocked-update')
}
expect(response).to have_http_status(:unprocessable_entity)
expect(material.reload.file.blob.filename.to_s).to eq('old.png')
end
end
end
describe 'GET /materials/download.zip' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) }
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) }
let!(:material_a) do
build_material(tag: tag_a, user: member_user,
file: dummy_upload(filename: 'a.png', body: 'zip-a'))
end
let!(:material_b) do
build_material(tag: tag_b, user: member_user,
file: dummy_upload(filename: 'b.png', body: 'zip-b'))
end
before do
MaterialExportItem.create!(material: material_a, profile: 'legacy_drive',
export_path: '素材/a.png',
created_by_user: member_user)
MaterialExportItem.create!(material: material_b, profile: 'legacy_drive',
export_path: '素材/b.png',
created_by_user: member_user)
end
it 'uses material_export_items.export_path as ZIP entry paths' do
get '/materials/download.zip', params: { profile: 'legacy_drive' }
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('application/zip')
expect(response.body.b).to include('素材/a.png'.b)
expect(response.body.b).to include('素材/b.png'.b)
end
it 'filters by tag_id' do
get '/materials/download.zip', params: { profile: 'legacy_drive', tag_id: tag_a.id }
expect(response).to have_http_status(:ok)
expect(response.body.b).to include('素材/a.png'.b)
expect(response.body.b).not_to include('素材/b.png'.b)
end
it 'does not include suppressed materials' do
material_b.update!(file_suppressed_at: Time.current,
file_suppression_reason: 'copyright_high_risk')
get '/materials/download.zip', params: { profile: 'legacy_drive' }
expect(response).to have_http_status(:ok)
expect(response.body.b).to include('素材/a.png'.b)
expect(response.body.b).not_to include('素材/b.png'.b)
end
end
describe 'PATCH /materials/:id/suppress_file' do
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_suppress'), category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'suppress.png'))
end
it 'allows admin to suppress a file and records a suppress version' do
sign_in_as(admin_user)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
patch "/materials/#{ material.id }/suppress_file",
params: { reason: 'copyright_high_risk' }
end.to change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:ok)
material.reload
expect(material.file_suppressed_at).to be_present
expect(material.file_suppressed_by_user).to eq(admin_user)
expect(material.file_suppression_reason).to eq('copyright_high_risk')
expect(material.material_versions.order(:version_no).last.event_type).to eq('suppress')
expect(json['file']).to be_nil
expect(json['file_suppressed_at']).to be_present
end
it 'purges blob when purge=true is requested' do
sign_in_as(admin_user)
old_blob_id = material.file.blob.id
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
patch "/materials/#{ material.id }/suppress_file",
params: { reason: 'copyright_takedown', purge: '1' }
end.to have_enqueued_job(ActiveStorage::PurgeJob)
expect(response).to have_http_status(:ok)
version = material.material_versions.order(:version_no).last
expect(version.event_type).to eq('suppress')
expect(version.file_blob_id).to eq(old_blob_id)
expect(version.file_filename).to eq('suppress.png')
expect(version.file_sha256).to be_present
end
it 'rejects member suppression' do
sign_in_as(member_user)
patch "/materials/#{ material.id }/suppress_file",
params: { reason: 'copyright_high_risk' }
expect(response).to have_http_status(:forbidden)
end end
end end
describe 'DELETE /materials/:id' do describe 'DELETE /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) } let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material)
end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
end end
+1 -1
ファイルの表示
@@ -69,7 +69,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/materials" element={<MaterialBasePage/>}> <Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/> <Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/> <Route path="new" element={<MaterialNewPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/> <Route path=":id" element ={<MaterialDetailPage user={user}/>}/>
</Route> </Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */} {/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/> <Route path="/wiki" element={<WikiSearchPage/>}/>
+298 -60
ファイルの表示
@@ -1,23 +1,31 @@
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 PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { apiGet } from '@/lib/api' import { materialsKeys } from '@/lib/queryKeys'
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
import type { FC, ReactNode } from 'react' import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { Tag } from '@/types' import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
type TagWithDepth = Tag & { const FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
hasChildren: boolean
children: TagWithDepth[] } const FILTER_LABELS: Record<MaterialFilter, string> = {
present: '素材あり',
missing: '素材なし',
any: 'すべて',
}
const setChildrenById = ( const setChildrenById = (
tags: TagWithDepth[], tags: MaterialSidebarTag[],
targetId: number, targetId: number,
children: TagWithDepth[], children: MaterialSidebarTag[],
): TagWithDepth[] => ( ): MaterialSidebarTag[] => (
tags.map (tag => { tags.map (tag => {
if (tag.id === targetId) if (tag.id === targetId)
return { ...tag, children } return { ...tag, children }
@@ -25,75 +33,305 @@ const setChildrenById = (
if (tag.children.length === 0) if (tag.children.length === 0)
return tag return tag
return { ...tag, return { ...tag, children: setChildrenById (tag.children, targetId, children) }
children: (setChildrenById (tag.children, targetId, children)
.filter (t => t.category !== 'meme' || t.hasChildren)) }
})) }))
const MaterialSidebar: FC = () => { const materialPath = (
const [tags, setTags] = useState<TagWithDepth[]> ([]) tagName: string,
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) materialFilter: MaterialFilter,
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ }) ): string => `/materials?tag=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }`
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 })
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 () }` : '' }`)
}
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 (() => { useEffect (() => {
void (async () => { if (open && data && tag.children.length === 0)
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth')) onChildren (tag.id, data)
.filter (t => t.category !== 'meme' || t.hasChildren)) }, [data, onChildren, open, tag.children.length, tag.id])
}) ()
}, [])
const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => ( return (
ts.map (t => ( <Fragment>
<Fragment key={t.id}>
<li> <li>
<div className="flex"> <div className="flex">
<div className="flex-none w-4"> <div className="flex-none w-4">
{t.hasChildren && ( {tag.hasChildren && (
<a <button
href="#" type="button"
onClick={async e => { onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
e.preventDefault () className="text-neutral-500 dark:text-stone-400">
if (!(tagFetchedFlags[t.id])) {open ? <>&minus;</> : '+'}
{ </button>)}
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>
<div className="flex-1 truncate"> <div className="flex-1 truncate">
<TagLink <TagLink
tag={t} tag={sidebarTagToTag (tag)}
nestLevel={nestLevel} nestLevel={nestLevel}
title={t.name} title={tag.name}
withCount={false} withCount={false}
withWiki={false} withWiki={false}
to={`/materials?tag=${ encodeURIComponent (t.name) }`}/> to={materialPath (tag.name, materialFilter)}/>
</div> </div>
</div> </div>
</li> </li>
{openTags[t.id] && renderTags (t.children, nestLevel + 1)} {open && tag.children.length > 0 && (
</Fragment>)))
return (
<SidebarComponent>
<ul> <ul>
{renderTags (tags)} {tag.children.map (child => (
</ul> <MaterialTreeNode
</SidebarComponent>) 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">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm font-medium text-stone-700 dark:text-stone-200"></span>
<PrefetchLink
to={`/materials?unclassified=1&material_filter=${ materialFilter }`}
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
</PrefetchLink>
</div>
<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}/>
<div>
<PrefetchLink
to={`/materials?unclassified=1&material_filter=${ materialFilter }`}
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
</PrefetchLink>
</div>
{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 export default MaterialSidebar
+114
ファイルの表示
@@ -0,0 +1,114 @@
import {
apiGet,
isApiError,
apiPatch,
apiPost,
apiPut,
} from '@/lib/api'
import type {
Material,
MaterialFilter,
MaterialSidebarTag,
MaterialTagTree,
} from '@/types'
export type FetchMaterialsParams = {
page?: number
limit?: number
tagId?: number | null
parentId?: number | null
unclassified?: boolean
}
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 (
{ page, limit, tagId, parentId, unclassified }: FetchMaterialsParams,
): Promise<MaterialIndexResponse> =>
await apiGet ('/materials', { params: {
...(page != null && { page }),
...(limit != null && { limit }),
...(tagId != null && { tag_id: tagId }),
...(parentId != null && { parent_id: parentId }),
...(unclassified && { unclassified: '1' }),
} })
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)
+26 -1
ファイルの表示
@@ -1,4 +1,9 @@
import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types' import type {
FetchNicoTagsParams,
FetchPostsParams,
FetchTagsParams,
MaterialFilter,
} from '@/types'
export const postsKeys = { export const postsKeys = {
root: ['posts'] as const, root: ['posts'] as const,
@@ -25,6 +30,26 @@ export const tagsKeys = {
['tags', 'changes', p] as const, ['tags', 'changes', p] as const,
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const } deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }
export const materialsKeys = {
root: ['materials'] as const,
index: (p: {
page?: number
limit?: number
tagId?: number | null
parentId?: number | null
unclassified?: boolean
}) => ['materials', 'index', p] as const,
byTagName: (name: string, materialFilter: MaterialFilter) =>
['materials', 'tag', name, materialFilter] as const,
show: (id: string) => ['materials', id] as const,
tree: (p: {
parentId?: number | null
materialFilter: MaterialFilter
}) => ['materials', 'tree', p] as const,
unclassified: (p: { page?: number; limit?: number } = { }) =>
['materials', 'unclassified', p] as const,
}
export const wikiKeys = { export const wikiKeys = {
root: ['wiki'] as const, root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const, index: (p: { title?: string }) => ['wiki', 'index', p] as const,
+4 -1
ファイルの表示
@@ -8,6 +8,7 @@ import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (), apiGet: vi.fn (),
apiPatch: vi.fn (),
apiPut: vi.fn (), apiPut: vi.fn (),
})) }))
@@ -26,7 +27,7 @@ vi.mock ('@/components/ui/use-toast', () => toastApi)
const renderPage = () => const renderPage = () =>
renderWithProviders ( renderWithProviders (
<Routes> <Routes>
<Route path="/materials/:id" element={<MaterialDetailPage/>}/> <Route path="/materials/:id" element={<MaterialDetailPage user={null}/>}/>
</Routes>, </Routes>,
{ route: '/materials/8' }, { route: '/materials/8' },
) )
@@ -73,6 +74,7 @@ describe ('MaterialDetailPage', () => {
const textboxes = screen.getAllByRole ('textbox') const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'new' } }) fireEvent.change (textboxes[0], { target: { value: 'new' } })
fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } }) fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } })
fireEvent.change (textboxes[2], { target: { value: '素材/new.png' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' })) fireEvent.click (screen.getByRole ('button', { name: '更新' }))
await waitFor (() => { await waitFor (() => {
@@ -81,6 +83,7 @@ describe ('MaterialDetailPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('new') expect (formData.get ('tag')).toBe ('new')
expect (formData.get ('url')).toBe ('https://example.com/ref') expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' }) expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' })
}) })
}) })
+123 -61
ファイルの表示
@@ -1,3 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
@@ -13,35 +14,60 @@ import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPut } from '@/lib/api' import {
suppressMaterialFile,
fetchMaterial,
updateMaterial,
} from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react' import type { FC } from 'react'
import type { Material, Tag } from '@/types' import type { User } from '@/types'
type MaterialWithTag = Material & { tag: Tag } type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
type MaterialFormField = 'tag' | 'file' | 'url'
const MaterialDetailPage: FC = () => { const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
const { id } = useParams () const { id } = useParams ()
const qc = useQueryClient ()
const [exportPath, setExportPath] = useState ('')
const [file, setFile] = useState<File | null> (null) const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('') const [filePreview, setFilePreview] = useState ('')
const [loading, setLoading] = useState (false)
const [material, setMaterial] = useState<MaterialWithTag | null> (null)
const [sending, setSending] = useState (false)
const [tag, setTag] = useState ('') const [tag, setTag] = useState ('')
const [url, setURL] = useState ('') const [url, setURL] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> () useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => { const { data: material, isError, isLoading } = useQuery ({
clearValidationErrors () queryKey: materialsKeys.show (id ?? ''),
queryFn: () => fetchMaterial (id ?? ''),
enabled: id != null,
})
useEffect (() => {
if (!(material))
return
setTag (material.tag.name)
setURL (material.url ?? '')
setExportPath (material.exportPaths.legacyDrive ?? '')
if (material.file && material.contentType)
{
setFilePreview (material.file)
setFile (null)
}
}, [material])
const invalidateMaterialQueries = async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
}
const updateMutation = useMutation ({
mutationFn: async () => {
const formData = new FormData const formData = new FormData
if (tag.trim ()) if (tag.trim ())
formData.append ('tag', tag) formData.append ('tag', tag)
@@ -49,51 +75,50 @@ const MaterialDetailPage: FC = () => {
formData.append ('file', file) formData.append ('file', file)
if (url.trim ()) if (url.trim ())
formData.append ('url', url) formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
try return await updateMaterial (id ?? '', formData)
{ },
setSending (true) onSuccess: async data => {
const data = await apiPut<Material> (`/materials/${ id }`, formData) qc.setQueryData (materialsKeys.show (id ?? ''), data)
setMaterial (data) await invalidateMaterialQueries ()
toast ({ title: '更新成功!' }) toast ({ title: '更新成功!' })
} },
catch (e) onError: error => {
{ applyValidationError (error)
applyValidationError (e)
toast ({ title: '更新失敗……', description: '入力を見直してください.' }) toast ({ title: '更新失敗……', description: '入力を見直してください.' })
} },
finally })
{
setSending (false) 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 ()
} }
useEffect (() => { const handleSuppress = () => {
if (!(id)) const reason = window.prompt ('抑止理由を入力してください。')
if (reason == null || reason.trim () === '')
return
if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。'))
return return
void (async () => { suppressMutation.mutate (reason)
try
{
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 ?? '')
}
finally
{
setLoading (false)
}
}) ()
}, [id])
return ( return (
<MainArea> <MainArea>
@@ -102,7 +127,15 @@ const MaterialDetailPage: FC = () => {
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title> <title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
</Helmet>)} </Helmet>)}
{loading ? 'Loading...' : (material && ( {isLoading ? 'Loading...' : isError ? (
<p className="text-red-600 dark:text-red-300">
</p>
) : material == null ? (
<p className="text-stone-700 dark:text-stone-300">
</p>
) : (
<> <>
<PageTitle> <PageTitle>
<TagLink <TagLink
@@ -111,7 +144,16 @@ const MaterialDetailPage: FC = () => {
withCount={false}/> withCount={false}/>
</PageTitle> </PageTitle>
{(material.file && material.contentType) && ( {material.fileSuppressedAt && (
<div className="mb-4 rounded border border-red-300 bg-red-50 p-3
text-red-900 dark:border-red-800 dark:bg-red-950
dark:text-red-100">
<span></span>
{material.fileSuppressionReason && (
<span> : {material.fileSuppressionReason}</span>)}
</div>)}
{(!material.fileSuppressedAt && material.file && material.contentType) && (
(/image\/.*/.test (material.contentType) && ( (/image\/.*/.test (material.contentType) && (
<img src={material.file} alt={material.tag.name || undefined}/>)) <img src={material.file} alt={material.tag.name || undefined}/>))
|| (/video\/.*/.test (material.contentType) && ( || (/video\/.*/.test (material.contentType) && (
@@ -127,10 +169,9 @@ const MaterialDetailPage: FC = () => {
</Tab> </Tab>
<Tab name="編輯"> <Tab name="編輯">
<div className="max-w-wl pt-2 space-y-4"> <div className="max-w-wl space-y-4 pt-2">
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
{/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}> <FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<TagInput <TagInput
@@ -140,7 +181,6 @@ const MaterialDetailPage: FC = () => {
setValue={setTag}/>)} setValue={setTag}/>)}
</FormField> </FormField>
{/* ファイル */}
<FormField label="ファイル" messages={fieldErrors.file}> <FormField label="ファイル" messages={fieldErrors.file}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
@@ -150,9 +190,10 @@ const MaterialDetailPage: FC = () => {
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
onChange={e => { onChange={e => {
const f = e.target.files?.[0] const nextFile = e.target.files?.[0]
setFile (f ?? null) setFile (nextFile ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '') setFilePreview (
nextFile ? URL.createObjectURL (nextFile) : '')
}}/> }}/>
{(file && filePreview) && ( {(file && filePreview) && (
(/image\/.*/.test (file.type) && ( (/image\/.*/.test (file.type) && (
@@ -177,7 +218,6 @@ const MaterialDetailPage: FC = () => {
</>)} </>)}
</FormField> </FormField>
{/* 参考 URL */}
<FormField label="参考 URL" messages={fieldErrors.url}> <FormField label="参考 URL" messages={fieldErrors.url}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input <input
@@ -189,17 +229,39 @@ const MaterialDetailPage: FC = () => {
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
{/* 送信 */} <FormField label="ZIP 出力パス" messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => (
<input
type="text"
value={exportPath}
onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
<div className="flex flex-wrap gap-2">
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" className="rounded bg-blue-600 px-4 py-2 text-white
disabled={sending}> disabled:bg-gray-400"
disabled={updateMutation.isPending}>
</Button> </Button>
{user?.role === 'admin' && !material.fileSuppressedAt && (
<Button
type="button"
variant="destructive"
onClick={handleSuppress}
disabled={suppressMutation.isPending}>
</Button>)}
</div>
</div> </div>
</Tab> </Tab>
</TabGroup> </TabGroup>
</>))} </>)}
</MainArea>) </MainArea>)
} }
+238 -77
ファイルの表示
@@ -1,39 +1,56 @@
import { Fragment, useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import nikumaru from '@/assets/fonts/nikumaru.otf' import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import SectionTitle from '@/components/common/SectionTitle' import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { API_BASE_URL, SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api' import {
fetchMaterials,
fetchMaterialTagByName,
parseMaterialFilter,
} from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
import type { Material, Tag } from '@/types' import type { Material, MaterialFilter, MaterialTagTree } from '@/types'
type TagWithMaterial = Omit<Tag, 'children'> & { const MATERIAL_FILTER_LABELS: Record<MaterialFilter, string> = {
children: TagWithMaterial[] present: '素材あり',
material: Material | null } missing: '素材なし',
any: 'すべて',
}
const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => { const MaterialCard = ({ tag }: { tag: MaterialTagTree }) => {
if (!(tag.material)) if (!(tag.material))
return return null
return ( return (
<PrefetchLink <PrefetchLink
to={`/materials/${ tag.material.id }`} to={`/materials/${ tag.material.id }`}
className="block w-40 h-40"> className="block h-40 w-40">
<div <div
className="w-full h-full overflow-hidden rounded-xl shadow className={`h-full w-full overflow-hidden rounded-xl shadow text-center
text-center content-center text-4xl" content-center text-4xl ${
tag.material.fileSuppressedAt
? 'border-2 border-red-300 bg-red-50 text-base text-red-900 ' +
'dark:border-red-800 dark:bg-red-950 dark:text-red-100'
: 'bg-white text-stone-900 dark:bg-stone-900 dark:text-stone-100' }`}
style={{ fontFamily: 'Nikumaru' }}> style={{ fontFamily: 'Nikumaru' }}>
{(tag.material.contentType && /image\/.*/.test (tag.material.contentType)) {tag.material.fileSuppressedAt
? <span></span>
: (tag.material.contentType && /image\/.*/.test (tag.material.contentType))
? <img src={tag.material.file || undefined}/> ? <img src={tag.material.file || undefined}/>
: <span></span>} : <span></span>}
</div> </div>
@@ -41,53 +58,40 @@ const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
} }
const MaterialListPage: FC = () => { const MaterialList = ({ materials }: { materials: Material[] }) => (
const [loading, setLoading] = useState (false) <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
const [tag, setTag] = useState<TagWithMaterial | null> (null) {materials.map (material => (
<article
key={material.id}
className={`rounded-2xl border p-4 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'
: 'border-stone-200 bg-white text-stone-900 dark:border-stone-700 ' +
'dark:bg-stone-900 dark:text-stone-100'}`}>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="font-medium text-stone-900 dark:text-stone-100">
{material.tag?.name ?? '未分類素材'}
</h2>
{material.fileSuppressedAt && (
<p className="mt-1 text-sm text-red-700 dark:text-red-200"></p>)}
</div>
<PrefetchLink
to={`/materials/${ material.id }`}
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
</PrefetchLink>
</div>
{material.exportPaths.legacyDrive && (
<p className="mt-3 break-all text-sm text-stone-600 dark:text-stone-300">
{material.exportPaths.legacyDrive}
</p>)}
</article>))}
</div>)
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
useEffect (() => { const MaterialTagTreeView = ({ tag }: { tag: MaterialTagTree }) => (
if (!(tagQuery))
{
setTag (null)
return
}
void (async () => {
try
{
setLoading (true)
setTag (
await apiGet<TagWithMaterial> (
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
}
finally
{
setLoading (false)
}
}) ()
}, [location.search, tagQuery])
return (
<MainArea>
<Helmet>
<style>
{`
@font-face
{
font-family: 'Nikumaru';
src: url(${ nikumaru }) format('opentype');
}`}
</style>
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
</Helmet>
{loading ? 'Loading...' : (
tag
? (
<> <>
<PageTitle> <PageTitle>
<TagLink <TagLink
@@ -98,17 +102,16 @@ const MaterialListPage: FC = () => {
? `/materials/${ tag.material.id }` ? `/materials/${ tag.material.id }`
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/> : `/materials?tag=${ encodeURIComponent (tag.name) }`}/>
</PageTitle> </PageTitle>
{(!(tag.material) && tag.category !== 'meme') && ( {(!tag.material && tag.hasMaterial !== true && tag.category !== 'meme') && (
<div className="-mt-2"> <div className="-mt-2">
<PrefetchLink <PrefetchLink to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
</PrefetchLink> </PrefetchLink>
</div>)} </div>)}
<MaterialCard tag={tag}/> <MaterialCard tag={tag}/>
<div className="ml-2"> <div className="ml-2 overflow-x-auto pb-2">
{tag.children.map (c2 => ( {tag.children.map (c2 => (
<Fragment key={c2.id}> <Fragment key={c2.id}>
<SectionTitle> <SectionTitle>
@@ -118,10 +121,9 @@ const MaterialListPage: FC = () => {
withCount={false} withCount={false}
to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/> to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/>
</SectionTitle> </SectionTitle>
{(!(c2.material) && c2.category !== 'meme') && ( {(!c2.material && c2.hasMaterial !== true && c2.category !== 'meme') && (
<div className="-mt-4"> <div className="-mt-4">
<PrefetchLink <PrefetchLink to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
</PrefetchLink> </PrefetchLink>
</div>)} </div>)}
@@ -138,11 +140,10 @@ const MaterialListPage: FC = () => {
withCount={false} withCount={false}
to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/> to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/>
</SubsectionTitle> </SubsectionTitle>
{(!(c3.material) && c3.category !== 'meme') && ( {(!c3.material && c3.hasMaterial !== true && c3.category !== 'meme') && (
<div className="-mt-2"> <div className="-mt-2">
<PrefetchLink <PrefetchLink
to={`/materials/new?tag=${ to={`/materials/new?tag=${ encodeURIComponent (c3.name) }`}>
encodeURIComponent (c3.name) }`}>
</PrefetchLink> </PrefetchLink>
</div>)} </div>)}
@@ -153,15 +154,175 @@ const MaterialListPage: FC = () => {
</Fragment>))} </Fragment>))}
</div> </div>
</>) </>)
: (
const MaterialSearchTop: FC<{
materialFilter: MaterialFilter
setMaterialFilter: (value: MaterialFilter) => void
tagName: string
setTagName: (value: string) => void
}> = ({ materialFilter, setMaterialFilter, tagName, setTagName }) => {
const navigate = useNavigate ()
const location = useLocation ()
const handleSearch = () => {
const qs = new URLSearchParams (location.search)
if (tagName.trim ())
qs.set ('tag', tagName.trim ())
else
qs.delete ('tag')
qs.delete ('unclassified')
qs.set ('material_filter', materialFilter)
navigate (`/materials?${ qs.toString () }`)
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<PageTitle></PageTitle>
<section className="rounded-3xl border border-stone-200 bg-white p-5 shadow-sm
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-4">
<FormField label="タグ名検索">
{() => (
<TagInput
value={tagName}
setValue={setTagName}/>)}
</FormField>
<FormField label="素材状態">
{({ invalid }) => (
<select
value={materialFilter}
onChange={e => setMaterialFilter (e.target.value as MaterialFilter)}
className={inputClass (invalid)}>
{Object.entries (MATERIAL_FILTER_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
</option>))}
</select>)}
</FormField>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleSearch}
className="rounded-full 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>
<PrefetchLink
to={`/materials?unclassified=1&material_filter=${ materialFilter }`}
className="rounded-full border border-stone-300 bg-white px-4 py-2
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>
<PrefetchLink
to="/materials/new"
className="rounded-full border border-stone-300 bg-white px-4 py-2
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-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>
</section>
</div>)
}
const MaterialListPage: FC = () => {
const location = useLocation ()
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const tagQuery = query.get ('tag') ?? ''
const unclassified = query.get ('unclassified') === '1'
const initialFilter = parseMaterialFilter (query.get ('material_filter'), 'present')
const [tagName, setTagName] = useState (tagQuery)
const [materialFilter, setMaterialFilter] = useState<MaterialFilter> (initialFilter)
const {
data: tag,
isLoading: tagLoading,
isError: tagError,
} = useQuery ({
queryKey: materialsKeys.byTagName (tagQuery, initialFilter),
queryFn: () => fetchMaterialTagByName (tagQuery, initialFilter),
enabled: tagQuery !== '' && !unclassified,
})
const {
data: unclassifiedData,
isLoading: unclassifiedLoading,
isError: unclassifiedError,
} = useQuery ({
queryKey: materialsKeys.unclassified ({ page: 1, limit: 50 }),
queryFn: () => fetchMaterials ({ page: 1, limit: 50, unclassified: true }),
enabled: unclassified,
})
useEffect (() => {
setTagName (tagQuery)
setMaterialFilter (initialFilter)
}, [initialFilter, tagQuery])
return (
<MainArea>
<Helmet>
<style>
{`
@font-face
{
font-family: 'Nikumaru';
src: url(${ nikumaru }) format('opentype');
}`}
</style>
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
</Helmet>
{unclassified ? (
<> <>
<p></p> <PageTitle></PageTitle>
<p></p> <div className="-mt-2 mb-4">
<ul> <PrefetchLink
<li><PrefetchLink to="/materials/new"></PrefetchLink></li> to={`/materials?material_filter=${ materialFilter }`}
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */} className="text-sm text-sky-700 underline underline-offset-2
</ul> dark:text-sky-300">
</>))}
</PrefetchLink>
</div>
{unclassifiedLoading && <p>Loading...</p>}
{unclassifiedError && (
<p className="text-red-600 dark:text-red-300"></p>)}
{unclassifiedData && unclassifiedData.materials.length === 0 && (
<p></p>)}
{unclassifiedData && unclassifiedData.materials.length > 0 && (
<MaterialList materials={unclassifiedData.materials}/>)}
</>) : tagQuery ? (
<>
{tagLoading && <p>Loading...</p>}
{tagError && (
<p className="text-red-600"></p>)}
{(!tagLoading && !tagError && !tag) && (
<p></p>)}
{tag && <MaterialTagTreeView tag={tag}/>}
</>) : (
<MaterialSearchTop
tagName={tagName}
setTagName={setTagName}
materialFilter={materialFilter}
setMaterialFilter={setMaterialFilter}/>)}
</MainArea>) </MainArea>)
} }
+37 -20
ファイルの表示
@@ -1,3 +1,4 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
@@ -11,16 +12,18 @@ import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api' import { createMaterial } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react' import type { FC } from 'react'
type MaterialFormField = 'tag' | 'file' | 'url' type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialNewPage: FC = () => { const MaterialNewPage: FC = () => {
const qc = useQueryClient ()
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? '' const tagQuery = query.get ('tag') ?? ''
@@ -29,15 +32,14 @@ const MaterialNewPage: FC = () => {
const [file, setFile] = useState<File | null> (null) const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('') const [filePreview, setFilePreview] = useState ('')
const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery) const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('') const [url, setURL] = useState ('')
const [exportPath, setExportPath] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> () useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => { const createMutation = useMutation ({
clearValidationErrors () mutationFn: async () => {
const formData = new FormData const formData = new FormData
if (tag) if (tag)
formData.append ('tag', tag) formData.append ('tag', tag)
@@ -45,23 +47,24 @@ const MaterialNewPage: FC = () => {
formData.append ('file', file) formData.append ('file', file)
if (url) if (url)
formData.append ('url', url) formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
try return await createMaterial (formData)
{ },
setSending (true) onSuccess: async () => {
await apiPost ('/materials', formData) await qc.invalidateQueries ({ queryKey: materialsKeys.root })
toast ({ title: '送信成功!' }) toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`) navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
} },
catch (e) onError: error => {
{ applyValidationError (error)
applyValidationError (e)
toast ({ title: '送信失敗……', description: '入力を見直してください.' }) toast ({ title: '送信失敗……', description: '入力を見直してください.' })
} },
finally })
{
setSending (false) const handleSubmit = () => {
} clearValidationErrors ()
createMutation.mutate ()
} }
return ( return (
@@ -133,11 +136,25 @@ const MaterialNewPage: FC = () => {
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
<FormField
label="ZIP 出力パス"
messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => (
<input
type="text"
value={exportPath}
onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 送信 */} {/* 送信 */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}> disabled={createMutation.isPending}>
</Button> </Button>
</Form> </Form>
+5
ファイルの表示
@@ -72,11 +72,16 @@ export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
id: 1, id: 1,
versionNo: 1,
tag: buildTag (), tag: buildTag (),
file: null, file: null,
url: null, url: null,
wikiPageBody: null, wikiPageBody: null,
contentType: null, contentType: null,
fileSuppressedAt: null,
fileSuppressionReason: null,
exportPaths: {},
exportItems: [],
createdAt: '2026-01-02T03:04:05.000Z', createdAt: '2026-01-02T03:04:05.000Z',
createdByUser: { id: 1, name: 'creator' }, createdByUser: { id: 1, name: 'creator' },
updatedAt: '2026-01-03T03:04:05.000Z', updatedAt: '2026-01-03T03:04:05.000Z',
+28
ファイルの表示
@@ -65,18 +65,46 @@ export type FetchNicoTagsOrder = `${ FetchNicoTagsOrderField }:${ 'asc' | 'desc'
export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at' export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at'
export type MaterialFilter = 'present' | 'missing' | 'any'
export type Material = { export type Material = {
id: number id: number
versionNo: number
tag: Tag tag: Tag
file: string | null file: string | null
url: string | null url: string | null
wikiPageBody?: string | null wikiPageBody?: string | null
contentType: string | null contentType: string | null
fileSuppressedAt: string | null
fileSuppressionReason: string | null
exportPaths: Record<string, string>
exportItems: MaterialExportItem[]
createdAt: string createdAt: string
createdByUser: { id: number; name: string } createdByUser: { id: number; name: string }
updatedAt: string updatedAt: string
updatedByUser: { id: number; name: string } } updatedByUser: { id: number; name: string } }
export type MaterialSidebarTag = {
id: number
name: string
category: Category
deprecated: boolean
hasChildren: boolean
hasMaterial: boolean
children: MaterialSidebarTag[] }
export type MaterialTagTree = Omit<Tag, 'children'> & {
hasChildren: boolean
hasMaterial: boolean
children: MaterialTagTree[]
material?: Material | null }
export type MaterialExportItem = {
id: number
profile: string
exportPath: string
enabled: boolean }
export type Menu = MenuItem[] export type Menu = MenuItem[]
export type MenuInvisibleItem = { export type MenuInvisibleItem = {