Browse Source

素材管理(#99) (#303)

#99

#99

#99

#99

#99

#99

#99

#99

#99

#99

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/303
feature/304
みてるぞ 1 week ago
parent
commit
7b15cb2c5a
33 changed files with 1742 additions and 192 deletions
  1. +2
    -2
      backend/Gemfile
  2. +21
    -0
      backend/Gemfile.lock
  3. +101
    -0
      backend/app/controllers/materials_controller.rb
  4. +71
    -5
      backend/app/controllers/tags_controller.rb
  5. +39
    -0
      backend/app/models/material.rb
  6. +3
    -0
      backend/app/models/tag.rb
  7. +24
    -0
      backend/app/representations/material_repr.rb
  8. +1
    -1
      backend/app/representations/tag_repr.rb
  9. +1
    -2
      backend/config/environments/production.rb
  10. +2
    -0
      backend/config/environments/test.rb
  11. +4
    -0
      backend/config/routes.rb
  12. +7
    -26
      backend/config/storage.yml
  13. +34
    -0
      backend/db/migrate/20260329034700_create_materials.rb
  14. +65
    -1
      backend/db/schema.rb
  15. +378
    -0
      backend/spec/requests/materials_spec.rb
  16. +151
    -0
      backend/spec/requests/tags_spec.rb
  17. +13
    -2
      frontend/src/App.tsx
  18. +97
    -0
      frontend/src/components/MaterialSidebar.tsx
  19. +38
    -59
      frontend/src/components/TagLink.tsx
  20. +1
    -1
      frontend/src/components/TagSidebar.tsx
  21. +10
    -3
      frontend/src/components/TopNav.tsx
  22. +97
    -0
      frontend/src/components/common/TagInput.tsx
  23. +3
    -1
      frontend/src/components/layout/MainArea.tsx
  24. +25
    -5
      frontend/src/components/layout/SidebarComponent.tsx
  25. +12
    -0
      frontend/src/pages/materials/MaterialBasePage.tsx
  26. +182
    -0
      frontend/src/pages/materials/MaterialDetailPage.tsx
  27. +166
    -0
      frontend/src/pages/materials/MaterialListPage.tsx
  28. +124
    -0
      frontend/src/pages/materials/MaterialNewPage.tsx
  29. +49
    -0
      frontend/src/pages/materials/MaterialSearchPage.tsx
  30. +1
    -1
      frontend/src/pages/posts/PostDetailPage.tsx
  31. +1
    -1
      frontend/src/pages/posts/PostListPage.tsx
  32. +6
    -82
      frontend/src/pages/posts/PostSearchPage.tsx
  33. +13
    -0
      frontend/src/types.ts

+ 2
- 2
backend/Gemfile View File

@@ -50,8 +50,6 @@ group :development, :test do
gem 'factory_bot_rails'
end



gem "mysql2", "~> 0.5.6"

gem "image_processing", "~> 1.14"
@@ -69,3 +67,5 @@ gem 'whenever', require: false
gem 'discard'

gem "rspec-rails", "~> 8.0", :groups => [:development, :test]

gem 'aws-sdk-s3', require: false

+ 21
- 0
backend/Gemfile.lock View File

@@ -73,6 +73,25 @@ GEM
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1231.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt_pbkdf (1.1.1)
bcrypt_pbkdf (1.1.1-arm64-darwin)
@@ -157,6 +176,7 @@ GEM
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.12.0)
jwt (2.10.1)
base64
@@ -441,6 +461,7 @@ PLATFORMS
x86_64-linux-musl

DEPENDENCIES
aws-sdk-s3
bootsnap
brakeman
diff-lcs


+ 101
- 0
backend/app/controllers/materials_controller.rb View File

@@ -0,0 +1,101 @@
class MaterialsController < ApplicationController
def index
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i

page = 1 if page < 1
limit = 1 if limit < 1

offset = (page - 1) * limit

tag_id = params[:tag_id].presence
parent_id = params[:parent_id].presence

q = Material.includes(:tag, :created_by_user).with_attached_file
q = q.where(tag_id:) if tag_id
q = q.where(parent_id:) if parent_id

count = q.count
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)

render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count }
end

def show
material =
Material
.includes(:tag)
.with_attached_file
.find_by(id: params[:id])
return head :not_found unless material

wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body

render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
end

def create
return head :unauthorized unless current_user

tag_name_raw = params[:tag].to_s.strip
file = params[:file]
url = params[:url].to_s.strip.presence
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)

tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag

material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(file)

if material.save
render json: MaterialRepr.base(material, host: request.base_url), status: :created
else
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end
end

def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?

material = Material.with_attached_file.find_by(id: params[:id])
return head :not_found unless material

tag_name_raw = params[:tag].to_s.strip
file = params[:file]
url = params[:url].to_s.strip.presence
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)

tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag

material.update!(tag:, url:, updated_by_user: current_user)
if file
material.file.attach(file)
else
material.file.purge
end

if material.save
render json: MaterialRepr.base(material, host: request.base_url)
else
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end
end

def destroy
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?

material = Material.find_by(id: params[:id])
return head :not_found unless material

material.discard
head :no_content
end
end

+ 71
- 5
backend/app/controllers/tags_controller.rb View File

@@ -33,11 +33,11 @@ class TagsController < ApplicationController
else
Tag.joins(:tag_name)
end
.includes(:tag_name, tag_name: :wiki_page)
.includes(:tag_name, :materials, tag_name: :wiki_page)
q = q.where(posts: { id: post_id }) if post_id.present?

q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where(category: category) if category
q = q.where(category:) if category
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
@@ -69,6 +69,44 @@ class TagsController < ApplicationController
render json: { tags: TagRepr.base(tags), count: q.size }
end

def with_depth
parent_tag_id = params[:parent].to_i
parent_tag_id = nil if parent_tag_id <= 0

tag_ids =
if parent_tag_id
TagImplication.where(parent_tag_id:).select(:tag_id)
else
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
end

tags =
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(category: [:meme, :character, :material])
.where(id: tag_ids)
.order('tag_names.name')
.distinct
.to_a

has_children_tag_ids =
if tags.empty?
[]
else
TagImplication
.joins(:tag)
.where(parent_tag_id: tags.map(&:id),
tags: { category: [:meme, :character, :material] })
.distinct
.pluck(:parent_tag_id)
end

render json: tags.map { |tag|
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
}
end

def autocomplete
q = params[:q].to_s.strip.sub(/\Anot:/i, '')

@@ -90,7 +128,7 @@ class TagsController < ApplicationController
end

base = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.includes(:tag_name, :materials, tag_name: :wiki_page)
base = base.where('tags.post_count > 0') if present_only

canonical_hit =
@@ -115,7 +153,7 @@ class TagsController < ApplicationController

def show
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(id: params[:id])
if tag
render json: TagRepr.base(tag)
@@ -129,7 +167,7 @@ class TagsController < ApplicationController
return head :bad_request if name.blank?

tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
if tag
render json: TagRepr.base(tag)
@@ -159,6 +197,18 @@ class TagsController < ApplicationController
render json: DeerjikistRepr.many(tag.deerjikists)
end

def materials_by_name
name = params[:name].to_s.strip
return head :bad_request if name.blank?

tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
return head :not_found unless tag

render json: build_tag_children(tag)
end

def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
@@ -178,4 +228,20 @@ class TagsController < ApplicationController

render json: TagRepr.base(tag)
end

private

def build_tag_children(tag)
material = tag.materials.first
file = nil
content_type = nil
if material&.file&.attached?
file = rails_storage_proxy_url(material.file, only_path: false)
content_type = material.file.blob.content_type
end

TagRepr.base(tag).merge(
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
material: material.as_json&.merge(file:, content_type:))
end
end

+ 39
- 0
backend/app/models/material.rb View File

@@ -0,0 +1,39 @@
class Material < ApplicationRecord
include MyDiscard

default_scope -> { kept }

belongs_to :parent, class_name: 'Material', optional: true
has_many :children, class_name: 'Material', foreign_key: :parent_id, dependent: :nullify

belongs_to :tag, optional: true
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true

has_one_attached :file, dependent: :purge

validates :tag_id, presence: true, uniqueness: true

validate :file_must_be_attached
validate :tag_must_be_material_category

def content_type
return nil unless file&.attached?

file.blob.content_type
end

private

def file_must_be_attached
return if url.present? || file.attached?

errors.add(:url, 'URL かファイルのどちらかは必須です.')
end

def tag_must_be_material_category
return if tag.blank? || tag.character? || tag.material?

errors.add(:tag, '素材カテゴリのタグを指定してください.')
end
end

+ 3
- 0
backend/app/models/tag.rb View File

@@ -31,6 +31,7 @@ class Tag < ApplicationRecord
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all

has_many :deerjikists, dependent: :delete_all
has_many :materials

belongs_to :tag_name
delegate :wiki_page, to: :tag_name
@@ -72,6 +73,8 @@ class Tag < ApplicationRecord

def has_wiki = wiki_page.present?

def material_id = materials.first&.id

def self.tagme
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
end


+ 24
- 0
backend/app/representations/material_repr.rb View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true


module MaterialRepr
BASE = { only: [:id, :url, :created_at, :updated_at],
methods: [:content_type],
include: { tag: TagRepr::BASE,
created_by_user: UserRepr::BASE,
updated_by_user: UserRepr::BASE } }.freeze

module_function

def base material, host:
material.as_json(BASE).merge(
file: if material.file.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:)
end)
end

def many materials, host:
materials.map { |m| base(m, host:) }
end
end

+ 1
- 1
backend/app/representations/tag_repr.rb View File

@@ -3,7 +3,7 @@

module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki] }.freeze
methods: [:name, :has_wiki, :material_id] }.freeze

module_function



+ 1
- 2
backend/config/environments/production.rb View File

@@ -18,8 +18,7 @@ Rails.application.configure do
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"

# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
config.active_storage.service = :r2

# Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true


+ 2
- 0
backend/config/environments/test.rb View File

@@ -50,4 +50,6 @@ Rails.application.configure do

# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true

Rails.application.routes.default_url_options[:host] = 'www.example.com'
end

+ 4
- 0
backend/config/routes.rb View File

@@ -9,9 +9,11 @@ Rails.application.routes.draw do
resources :tags, only: [:index, :show, :update] do
collection do
get :autocomplete
get :'with-depth', action: :with_depth

scope :name do
get ':name/deerjikists', action: :deerjikists_by_name
get ':name/materials', action: :materials_by_name
get ':name', action: :show_by_name
end
end
@@ -81,4 +83,6 @@ Rails.application.routes.draw do

resources :comments, controller: :theatre_comments, only: [:index, :create]
end

resources :materials, only: [:index, :show, :create, :update, :destroy]
end

+ 7
- 26
backend/config/storage.yml View File

@@ -6,29 +6,10 @@ local:
service: Disk
root: <%= Rails.root.join("storage") %>

# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket-<%= Rails.env %>

# Remember not to checkin your GCS keyfile to a repository
# google:
# service: GCS
# project: your_project
# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
# bucket: your_own_bucket-<%= Rails.env %>

# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
# microsoft:
# service: AzureStorage
# storage_account_name: your_account_name
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
# container: your_container_name-<%= Rails.env %>

# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]
r2:
service: S3
endpoint: <%= ENV['R2_ENDPOINT'] %>
access_key_id: <%= ENV['R2_ACCESS_KEY_ID'] %>
secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %>
bucket: <%= ENV['R2_BUCKET'] %>
region: auto

+ 34
- 0
backend/db/migrate/20260329034700_create_materials.rb View File

@@ -0,0 +1,34 @@
class CreateMaterials < ActiveRecord::Migration[8.0]
def change
create_table :materials do |t|
t.string :url
t.references :parent, index: true, foreign_key: { to_table: :materials }
t.references :tag, index: true, foreign_key: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.datetime :discarded_at, index: true
t.virtual :active_url, type: :string,
as: 'IF(discarded_at IS NULL, url, NULL)',
stored: false

t.index :active_url, unique: true
end

create_table :material_versions do |t|
t.references :material, null: false, foreign_key: true
t.integer :version_no, null: false
t.string :url, index: true
t.references :parent, index: true, foreign_key: { to_table: :materials }
t.references :tag, index: true, foreign_key: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.datetime :discarded_at, index: true

t.index [:material_id, :version_no],
unique: true,
name: 'index_material_versions_on_material_id_and_version_no'
end
end
end

+ 65
- 1
backend/db/schema.rb View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -56,6 +56,45 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end

create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false
t.integer "version_no", null: false
t.string "url"
t.bigint "parent_id"
t.bigint "tag_id"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
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 ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
t.index ["material_id"], name: "index_material_versions_on_material_id"
t.index ["parent_id"], name: "index_material_versions_on_parent_id"
t.index ["tag_id"], name: "index_material_versions_on_tag_id"
t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
t.index ["url"], name: "index_material_versions_on_url"
end

create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "url"
t.bigint "parent_id"
t.bigint "tag_id"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
t.index ["active_url"], name: "index_materials_on_active_url", unique: true
t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
t.index ["discarded_at"], name: "index_materials_on_discarded_at"
t.index ["parent_id"], name: "index_materials_on_parent_id"
t.index ["tag_id"], name: "index_materials_on_tag_id"
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
end

create_table "nico_tag_relations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "nico_tag_id", null: false
t.bigint "tag_id", null: false
@@ -239,6 +278,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
t.datetime "updated_at", null: false
end

create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end

create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false
t.text "body", null: false
@@ -254,6 +306,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
@@ -292,6 +345,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do

add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
add_foreign_key "material_versions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_by_user_id"
add_foreign_key "materials", "materials", column: "parent_id"
add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "post_similarities", "posts"
@@ -320,6 +382,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
add_foreign_key "user_ips", "users"
add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id"


+ 378
- 0
backend/spec/requests/materials_spec.rb View File

@@ -0,0 +1,378 @@
require 'rails_helper'

RSpec.describe 'Materials API', type: :request do
let!(:member_user) { create(:user, :member) }
let!(:guest_user) { create(:user) }

def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
end

def response_materials
json.fetch('materials')
end

def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil)
Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material|
material.file.attach(file) if file
material.save!
end
end

describe 'GET /materials' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) }
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) }

let!(:material_a) do
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
end

let!(:material_b) do
build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png'))
end

before do
old_time = Time.zone.local(2026, 3, 29, 1, 0, 0)
new_time = Time.zone.local(2026, 3, 29, 2, 0, 0)

material_a.update_columns(created_at: old_time, updated_at: old_time)
material_b.update_columns(created_at: new_time, updated_at: new_time)
end

it 'returns materials with count and metadata' do
get '/materials'

expect(response).to have_http_status(:ok)
expect(json).to include('materials', 'count')
expect(response_materials).to be_an(Array)
expect(json['count']).to eq(2)

row = response_materials.find { |m| m['id'] == material_b.id }
expect(row).to be_present
expect(row['tag']).to include(
'id' => tag_b.id,
'name' => 'material_index_b',
'category' => 'material'
)
expect(row['created_by_user']).to include(
'id' => member_user.id,
'name' => member_user.name
)
expect(row['content_type']).to eq('image/png')
end

it 'filters materials by tag_id' do
get '/materials', params: { tag_id: material_a.tag_id }

expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_a.id])
end

it 'filters materials by parent_id' do
get '/materials', params: { parent_id: material_a.id }

expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_b.id])
end

it 'paginates and keeps total count' do
get '/materials', params: { page: 2, limit: 1 }

expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
expect(response_materials.size).to eq(1)
expect(response_materials.first['id']).to eq(material_a.id)
end

it 'normalises invalid page and limit' do
get '/materials', params: { page: 0, limit: 0 }

expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
expect(response_materials.size).to eq(1)
expect(response_materials.first['id']).to eq(material_b.id)
end
end

describe 'GET /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) }
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
end

it 'returns a material with file, tag, and content_type' do
get "/materials/#{ material.id }"

expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => material.id,
'content_type' => 'image/png'
)
expect(json['file']).to be_present
expect(json['tag']).to include(
'id' => tag.id,
'name' => 'material_show',
'category' => 'material'
)
end

it 'returns 404 when material does not exist' do
get '/materials/999999999'
expect(response).to have_http_status(:not_found)
end
end

describe 'POST /materials' do
context 'when not logged in' do
before { sign_out }

it 'returns 401' do
post '/materials', params: {
tag: 'material_create_unauthorized',
file: dummy_upload
}

expect(response).to have_http_status(:unauthorized)
end
end

context 'when logged in' do
before { sign_in_as(guest_user) }

it 'returns 400 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload }

expect(response).to have_http_status(:bad_request)
end

it 'returns 400 when both file and url are blank' do
post '/materials', params: { tag: 'material_create_blank' }

expect(response).to have_http_status(:bad_request)
end

it 'creates a material with an attached file' do
expect do
post '/materials', params: {
tag: 'material_create_new',
file: dummy_upload(filename: 'created.png')
}
end.to change(Material, :count).by(1)
.and change(Tag, :count).by(1)
.and change(TagName, :count).by(1)

expect(response).to have_http_status(:created)

material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_new')
expect(material.tag.category).to eq('material')
expect(material.created_by_user).to eq(guest_user)
expect(material.updated_by_user).to eq(guest_user)
expect(material.file.attached?).to be(true)

expect(json['id']).to eq(material.id)
expect(json.dig('tag', 'name')).to eq('material_create_new')
expect(json['content_type']).to eq('image/png')
end

it 'returns 422 when the existing tag is not material/character' do
general_tag_name = TagName.create!(name: 'material_create_general_tag')
Tag.create!(tag_name: general_tag_name, category: :general)

post '/materials', params: {
tag: 'material_create_general_tag',
file: dummy_upload
}

expect(response).to have_http_status(:unprocessable_entity)
end

it 'persists url-only material' do
expect do
post '/materials', params: {
tag: 'material_create_url_only',
url: 'https://example.com/material-source'
}
end.to change(Material, :count).by(1)

expect(response).to have_http_status(:created)

material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_url_only')
expect(material.url).to eq('https://example.com/material-source')
expect(material.file.attached?).to be(false)
end

it 'returns the original url for url-only material' do
post '/materials', params: {
tag: 'material_create_url_only_response',
url: 'https://example.com/material-source'
}

expect(response).to have_http_status(:created)
expect(json['url']).to eq('https://example.com/material-source')
end
end
end

describe 'PUT /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) }
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
end

context 'when not logged in' do
before { sign_out }

it 'returns 401' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
file: dummy_upload(filename: 'new.png')
}

expect(response).to have_http_status(:unauthorized)
end
end

context 'when logged in but not member' do
before { sign_in_as(guest_user) }

it 'returns 403' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
file: dummy_upload(filename: 'new.png')
}

expect(response).to have_http_status(:forbidden)
end
end

context 'when member' do
before { sign_in_as(member_user) }

it 'returns 404 when material does not exist' do
put '/materials/999999999', params: {
tag: 'material_update_missing',
file: dummy_upload
}

expect(response).to have_http_status(:not_found)
end

it 'returns 400 when tag is blank' do
put "/materials/#{ material.id }", params: {
tag: ' ',
file: dummy_upload
}

expect(response).to have_http_status(:bad_request)
end

it 'returns 400 when both file and url are blank' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload'
}

expect(response).to have_http_status(:bad_request)
end

it 'updates tag, url, file, and updated_by_user' do
old_blob_id = material.file.blob.id

put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
url: 'https://example.com/updated-source',
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg')
}

expect(response).to have_http_status(:ok)

material.reload
expect(material.tag.name).to eq('material_update_new')
expect(material.tag.category).to eq('material')
expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(true)
expect(material.file.blob.id).not_to eq(old_blob_id)
expect(material.file.blob.filename.to_s).to eq('updated.jpg')
expect(material.file.blob.content_type).to eq('image/jpeg')

expect(json['id']).to eq(material.id)
expect(json['file']).to be_present
expect(json['content_type']).to eq('image/jpeg')
expect(json.dig('tag', 'name')).to eq('material_update_new')
end

it 'purges the existing file when file is omitted and url is provided' do
old_blob_id = material.file.blob.id

put "/materials/#{ material.id }", params: {
tag: 'material_update_remove_file',
url: 'https://example.com/updated-source'
}

expect(response).to have_http_status(:ok)

material.reload
expect(material.tag.name).to eq('material_update_remove_file')
expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(false)

expect(
ActiveStorage::Blob.where(id: old_blob_id).exists?
).to be(false)

expect(json['id']).to eq(material.id)
expect(json['file']).to be_nil
expect(json['content_type']).to be_nil
expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
expect(json['url']).to eq('https://example.com/updated-source')
end
end
end

describe 'DELETE /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) }
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
end

context 'when not logged in' do
before { sign_out }

it 'returns 401' do
delete "/materials/#{ material.id }"
expect(response).to have_http_status(:unauthorized)
end
end

context 'when logged in but not member' do
before { sign_in_as(guest_user) }

it 'returns 403' do
delete "/materials/#{ material.id }"
expect(response).to have_http_status(:forbidden)
end
end

context 'when member' do
before { sign_in_as(member_user) }

it 'returns 404 when material does not exist' do
delete '/materials/999999999'
expect(response).to have_http_status(:not_found)
end

it 'discards the material and returns 204' do
delete "/materials/#{ material.id }"

expect(response).to have_http_status(:no_content)
expect(Material.find_by(id: material.id)).to be_nil
expect(Material.with_discarded.find(material.id)).to be_discarded
end
end
end
end

+ 151
- 0
backend/spec/requests/tags_spec.rb View File

@@ -19,6 +19,17 @@ RSpec.describe 'Tags API', type: :request do
response_tags.map { |t| t.fetch('name') }
end

def dummy_material_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
end

def create_material(tag, user:, filename: 'dummy.png', type: 'image/png', url: nil)
Material.new(tag:, url:, created_by_user: user, updated_by_user: user).tap do |material|
material.file.attach(dummy_material_upload(filename:, type:)) if filename
material.save!
end
end

describe 'GET /tags' do
it 'returns tags with count and metadata' do
get '/tags'
@@ -359,4 +370,144 @@ RSpec.describe 'Tags API', type: :request do
end
end
end

describe 'GET /tags/with-depth' do
let!(:root_meme) do
Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme)
end

let!(:root_material) do
Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material)
end

let!(:hidden_general_root) do
Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general)
end

let!(:child_character) do
Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character)
end

let!(:grandchild_material) do
Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material)
end

let!(:child_general) do
Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general)
end

before do
TagImplication.create!(parent_tag: root_meme, tag: child_character)
TagImplication.create!(parent_tag: child_character, tag: grandchild_material)
TagImplication.create!(parent_tag: root_material, tag: child_general)
end

it 'returns only visible root tags and visible has_children flags' do
get '/tags/with-depth'

expect(response).to have_http_status(:ok)
expect(json.map { |t| t['name'] }).to eq([
'depth_a_root_meme',
'depth_b_root_material'
])

meme_row = json.find { |t| t['name'] == 'depth_a_root_meme' }
material_row = json.find { |t| t['name'] == 'depth_b_root_material' }

expect(meme_row['has_children']).to eq(true)
expect(meme_row['children']).to eq([])

expect(material_row['has_children']).to eq(false)
expect(material_row['children']).to eq([])

expect(json.map { |t| t['name'] }).not_to include('depth_hidden_general_root')
end

it 'returns children of the specified parent' do
get '/tags/with-depth', params: { parent: root_meme.id }

expect(response).to have_http_status(:ok)
expect(json.map { |t| t['name'] }).to eq(['depth_child_character'])

row = json.first
expect(row['category']).to eq('character')
expect(row['has_children']).to eq(true)
expect(row['children']).to eq([])
end
end

describe 'GET /tags/name/:name/materials' do
let!(:material_user) { create_member_user! }

let!(:root_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material)
end

let!(:child_a_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material)
end

let!(:child_b_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character)
end

let!(:grandchild_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material)
end

let!(:root_material) do
create_material(root_tag, user: material_user, filename: 'root.png')
end

let!(:child_a_material) do
create_material(child_a_tag, user: material_user, filename: 'child_a.png')
end

let!(:grandchild_material) do
create_material(grandchild_tag, user: material_user, filename: 'grandchild.png')
end

before do
TagImplication.create!(parent_tag: root_tag, tag: child_b_tag)
TagImplication.create!(parent_tag: root_tag, tag: child_a_tag)
TagImplication.create!(parent_tag: child_a_tag, tag: grandchild_tag)
end

it 'returns a tag tree with nested materials sorted by child name' do
get "/tags/name/#{ CGI.escape(root_tag.name) }/materials"

expect(response).to have_http_status(:ok)

expect(json).to include(
'id' => root_tag.id,
'name' => 'materials_root',
'category' => 'material'
)

expect(json['material']).to be_present
expect(json.dig('material', 'id')).to eq(root_material.id)
expect(json.dig('material', 'file')).to be_present
expect(json.dig('material', 'content_type')).to eq('image/png')

expect(json['children'].map { |t| t['name'] }).to eq([
'materials_child_a',
'materials_child_b'
])

child_a = json['children'].find { |t| t['name'] == 'materials_child_a' }
child_b = json['children'].find { |t| t['name'] == 'materials_child_b' }

expect(child_a.dig('material', 'id')).to eq(child_a_material.id)
expect(child_a['children'].map { |t| t['name'] }).to eq(['materials_grandchild'])
expect(child_a.dig('children', 0, 'material', 'id')).to eq(grandchild_material.id)

expect(child_b['material']).to be_nil
expect(child_b['children']).to eq([])
end

it 'returns 404 when the tag does not exist' do
get '/tags/name/no_such_tag_12345/materials'
expect(response).to have_http_status(:not_found)
end
end
end

+ 13
- 2
frontend/src/App.tsx View File

@@ -10,6 +10,11 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import PostDetailPage from '@/pages/posts/PostDetailPage'
@@ -41,7 +46,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
return (
<LayoutGroup id="gallery-shared">
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
@@ -51,6 +56,12 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
@@ -120,7 +131,7 @@ export default (() => {
<>
<RouteBlockerOverlay/>
<BrowserRouter>
<div className="flex flex-col h-screen w-screen">
<div className="flex flex-col h-dvh w-screen">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</div>


+ 97
- 0
frontend/src/components/MaterialSidebar.tsx View File

@@ -0,0 +1,97 @@
import { Fragment, useEffect, useState } from 'react'

import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { apiGet } from '@/lib/api'

import type { FC, ReactNode } from 'react'

import type { Tag } from '@/types'

type TagWithDepth = Tag & {
hasChildren: boolean
children: TagWithDepth[] }


const setChildrenById = (
tags: TagWithDepth[],
targetId: number,
children: TagWithDepth[],
): TagWithDepth[] => (
tags.map (tag => {
if (tag.id === targetId)
return { ...tag, children }

if (tag.children.length === 0)
return tag

return { ...tag,
children: (setChildrenById (tag.children, targetId, children)
.filter (t => t.category !== 'meme' || t.hasChildren)) }
}))


export default (() => {
const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })

useEffect (() => {
void (async () => {
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
.filter (t => t.category !== 'meme' || t.hasChildren))
}) ()
}, [])

const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => (
ts.map (t => (
<Fragment key={t.id}>
<li>
<div className="flex">
<div className="flex-none w-4">
{t.hasChildren && (
<a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(tagFetchedFlags[t.id]))
{
try
{
const data =
await apiGet<TagWithDepth[]> (
'/tags/with-depth', { params: { parent: String (t.id) } })
setTags (prev => setChildrenById (prev, t.id, data))
setTagFetchedFlags (prev => ({ ...prev, [t.id]: true }))
}
catch
{
;
}
}
setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) }))
}}>
{openTags[t.id] ? <>&minus;</> : '+'}
</a>)}
</div>
<div className="flex-1 truncate">
<TagLink
tag={t}
nestLevel={nestLevel}
title={t.name}
withCount={false}
withWiki={false}
to={`/materials?tag=${ encodeURIComponent (t.name) }`}/>
</div>
</div>
</li>
{openTags[t.id] && renderTags (t.children, nestLevel + 1)}
</Fragment>)))

return (
<SidebarComponent>
<ul>
{renderTags (tags)}
</ul>
</SidebarComponent>)
}) satisfies FC

+ 38
- 59
frontend/src/components/TagLink.tsx View File

@@ -1,8 +1,5 @@
import { useEffect, useState } from 'react'

import PrefetchLink from '@/components/PrefetchLink'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { apiGet } from '@/lib/api'
import { cn } from '@/lib/utils'

import type { ComponentProps, FC, HTMLAttributes } from 'react'
@@ -13,8 +10,7 @@ type CommonProps = {
tag: Tag
nestLevel?: number
withWiki?: boolean
withCount?: boolean
prefetch?: boolean }
withCount?: boolean }

type PropsWithLink =
& CommonProps
@@ -36,37 +32,7 @@ export default (({ tag,
linkFlg = true,
withWiki = true,
withCount = true,
prefetch = false,
...props }: Props) => {
const [havingWiki, setHavingWiki] = useState (true)

const wikiExists = async (tag: Tag) => {
if ('hasWiki' in tag)
{
setHavingWiki (tag.hasWiki)
return
}

const tagName = (tag as Tag).name

try
{
await apiGet (`/wiki/title/${ encodeURIComponent (tagName) }/exists`)
setHavingWiki (true)
}
catch
{
setHavingWiki (false)
}
}

useEffect (() => {
if (!(linkFlg) || !(withWiki))
return

wikiExists (tag)
}, [tag.name, linkFlg, withWiki])

const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
@@ -79,19 +45,39 @@ export default (({ tag,
<>
{(linkFlg && withWiki) && (
<span className="mr-1">
{havingWiki
{(tag.materialId != null || tag.hasWiki)
? (
<PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}>
?
</PrefetchLink>)
tag.materialId == null
? (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}>
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
?
</PrefetchLink>))
: (
<PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>)}
['character', 'material'].includes (tag.category)
? (
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } 素材情報が存在しません.`}>
!
</PrefetchLink>)
: (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>))}
</span>)}
{nestLevel > 0 && (
<span
@@ -108,19 +94,12 @@ export default (({ tag,
</>)}
{linkFlg
? (
prefetch
? <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>
: <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>)
<PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>)
: (
<span className={spanClass}
{...props}>


+ 1
- 1
frontend/src/components/TagSidebar.tsx View File

@@ -66,7 +66,7 @@ export default (({ posts, onClick }: Props) => {
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<motion.div layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} prefetch onClick={onClick}/>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>
</li>))) : [])}
</ul>


+ 10
- 3
frontend/src/components/TopNav.tsx View File

@@ -70,20 +70,27 @@ export default (({ user }: Props) => {
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '投稿追加', to: '/posts/new' },
{ name: '追加', to: '/posts/new' },
{ name: '履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'タグ一覧', to: '/tags', visible: true },
{ name: 'マスタ', to: '/tags' },
{ name: '別名タグ', to: '/tags/aliases', visible: false },
{ name: '上位タグ', to: '/tags/implications', visible: false },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
{ name: '素材', to: '/materials', subMenu: [
{ name: '一覧', to: '/materials' },
// { name: '検索', to: '/materials/search' },
{ name: '追加', to: '/materials/new' },
// { name: '履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: 'wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>第&thinsp;1&thinsp;会場</>, to: '/theatres/1' },
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
{ name: <>ニジカ放送局第&thinsp;1&thinsp;チャンネル</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }] },
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },


+ 97
- 0
frontend/src/components/common/TagInput.tsx View File

@@ -0,0 +1,97 @@
import { useState } from 'react'

import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api'

import type { FC, ChangeEvent, KeyboardEvent } from 'react'

import type { Tag } from '@/types'


type Props = {
value: string
setValue: (value: string) => void }

export default (({ value, setValue }: Props) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)

// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setValue (ev.target.value)

const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}

const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ')
parts[parts.length - 1] = tag.name
setValue (parts.join (' ') + ' ')
setSuggestions ([])
setActiveIndex (-1)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break

case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break

case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
break

case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
}

return (
<div className="relative">
<input
type="text"
value={value}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
}) satisfies FC<Props>

+ 3
- 1
frontend/src/components/layout/MainArea.tsx View File

@@ -8,6 +8,8 @@ type Props = {


export default (({ children, className }: Props) => (
<main className={cn ('flex-1 overflow-y-auto p-4', className)}>
<main className={cn ('flex-1 overflow-y-auto p-4',
'md:h-[calc(100dvh-88px)] md:overflow-y-auto',
className)}>
{children}
</main>)) satisfies FC<Props>

+ 25
- 5
frontend/src/components/layout/SidebarComponent.tsx View File

@@ -1,9 +1,29 @@
import React from 'react'
import { Helmet } from 'react-helmet-async'

type Props = { children: React.ReactNode }
import type { FC, ReactNode } from 'react'

type Props = { children: ReactNode }


export default (({ children }: Props) => (
<div
className="p-4 w-full md:w-64 md:h-full
md:h-[calc(100dvh-88px)] md:overflow-y-auto
sidebar">
<Helmet>
<style>
{`
.sidebar
{
direction: rtl;
}

.sidebar > *
{
direction: ltr;
}`}
</style>
</Helmet>

export default ({ children }: Props) => (
<div className="p-4 w-full md:w-64 md:h-full">
{children}
</div>)
</div>)) satisfies FC<Props>

+ 12
- 0
frontend/src/pages/materials/MaterialBasePage.tsx View File

@@ -0,0 +1,12 @@
import { Outlet } from 'react-router-dom'

import MaterialSidebar from '@/components/MaterialSidebar'

import type { FC } from 'react'


export default (() => (
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]">
<MaterialSidebar/>
<Outlet/>
</div>)) satisfies FC

+ 182
- 0
frontend/src/pages/materials/MaterialDetailPage.tsx View File

@@ -0,0 +1,182 @@
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'

import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPut } from '@/lib/api'

import type { FC } from 'react'

import type { Material, Tag } from '@/types'

type MaterialWithTag = Material & { tag: Tag }


export default (() => {
const { id } = useParams ()

const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [loading, setLoading] = useState (false)
const [material, setMaterial] = useState<MaterialWithTag | null> (null)
const [sending, setSending] = useState (false)
const [tag, setTag] = useState ('')
const [url, setURL] = useState ('')

const handleSubmit = async () => {
const formData = new FormData
if (tag.trim ())
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url.trim ())
formData.append ('url', url)

try
{
setSending (true)
const data = await apiPut<Material> (`/materials/${ id }`, formData)
setMaterial (data)
toast ({ title: '更新成功!' })
}
catch
{
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
}

useEffect (() => {
if (!(id))
return

void (async () => {
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 (
<MainArea>
{material && (
<Helmet>
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
</Helmet>)}

{loading ? 'Loading...' : (material && (
<>
<PageTitle>
<TagLink
tag={material.tag}
withWiki={false}
withCount={false}/>
</PageTitle>

{(material.file && material.contentType) && (
(/image\/.*/.test (material.contentType) && (
<img src={material.file} alt={material.tag.name || undefined}/>))
|| (/video\/.*/.test (material.contentType) && (
<video src={material.file} controls/>))
|| (/audio\/.*/.test (material.contentType) && (
<audio src={material.file} controls/>)))}

<TabGroup>
<Tab name="Wiki">
<WikiBody
title={material.tag.name}
body={material.wikiPageBody ?? undefined}/>
</Tab>

<Tab name="編輯">
<div className="max-w-wl pt-2 space-y-4">
{/* タグ */}
<div>
<Label>タグ</Label>
<TagInput value={tag} setValue={setTag}/>
</div>

{/* ファイル */}
<div>
<Label>ファイル</Label>
<input
type="file"
accept="image/*,video/*,audio/*"
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
その形式のファイルには対応していません.
</p>))}
</div>

{/* 参考 URL */}
<div>
<Label>参考 URL</Label>
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>

{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
更新
</Button>
</div>
</Tab>
</TabGroup>
</>))}
</MainArea>)
}) satisfies FC

+ 166
- 0
frontend/src/pages/materials/MaterialListPage.tsx View File

@@ -0,0 +1,166 @@
import { Fragment, useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'

import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
import PageTitle from '@/components/common/PageTitle'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'

import type { FC } from 'react'

import type { Material, Tag } from '@/types'

type TagWithMaterial = Omit<Tag, 'children'> & {
children: TagWithMaterial[]
material: Material | null }


const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
if (!(tag.material))
return

return (
<PrefetchLink
to={`/materials/${ tag.material.id }`}
className="block w-40 h-40">
<div
className="w-full h-full overflow-hidden rounded-xl shadow
text-center content-center text-4xl"
style={{ fontFamily: 'Nikumaru' }}>
{(tag.material.contentType && /image\/.*/.test (tag.material.contentType))
? <img src={tag.material.file || undefined}/>
: <span>照会</span>}
</div>
</PrefetchLink>)
}


export default (() => {
const [loading, setLoading] = useState (false)
const [tag, setTag] = useState<TagWithMaterial | null> (null)

const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''

useEffect (() => {
if (!(tagQuery))
{
setTag (null)
return
}

void (async () => {
try
{
setLoading (true)
setTag (
await apiGet<TagWithMaterial> (
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
}
finally
{
setLoading (false)
}
}) ()
}, [location.search])

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>
<TagLink
tag={tag}
withWiki={false}
withCount={false}
to={tag.material
? `/materials/${ tag.material.id }`
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/>
</PageTitle>
{(!(tag.material) && tag.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
追加
</PrefetchLink>
</div>)}

<MaterialCard tag={tag}/>

<div className="ml-2">
{tag.children.map (c2 => (
<Fragment key={c2.id}>
<SectionTitle>
<TagLink
tag={c2}
withWiki={false}
withCount={false}
to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/>
</SectionTitle>
{(!(c2.material) && c2.category !== 'meme') && (
<div className="-mt-4">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
追加
</PrefetchLink>
</div>)}

<MaterialCard tag={c2}/>

<div className="ml-2">
{c2.children.map (c3 => (
<Fragment key={c3.id}>
<SubsectionTitle>
<TagLink
tag={c3}
withWiki={false}
withCount={false}
to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/>
</SubsectionTitle>
{(!(c3.material) && c3.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${
encodeURIComponent (c3.name) }`}>
追加
</PrefetchLink>
</div>)}

<MaterialCard tag={c3}/>
</Fragment>))}
</div>
</Fragment>))}
</div>
</>)
: (
<>
<p>左のリストから照会したいタグを選択してください。</p>
<p>もしくは……</p>
<ul>
<li><PrefetchLink to="/materials/new">素材を新規追加する</PrefetchLink></li>
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
</ul>
</>))}
</MainArea>)
}) satisfies FC

+ 124
- 0
frontend/src/pages/materials/MaterialNewPage.tsx View File

@@ -0,0 +1,124 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'

import Form from '@/components/common/Form'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'

import type { FC } from 'react'


export default (() => {
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''

const navigate = useNavigate ()

const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('')

const handleSubmit = async () => {
const formData = new FormData
if (tag)
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url)
formData.append ('url', url)

try
{
setSending (true)
await apiPost ('/materials', formData)
toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
}
catch
{
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
}

return (
<MainArea>
<Helmet>
<title>{`素材追加 | ${ SITE_TITLE }`}</title>
</Helmet>

<Form>
<PageTitle>素材追加</PageTitle>

{/* タグ */}
<div>
<Label>タグ</Label>
<TagInput value={tag} setValue={setTag}/>
</div>

{/* ファイル */}
<div>
<Label>ファイル</Label>
<input
type="file"
accept="image/*,video/*,audio/*"
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
その形式のファイルには対応していません.
</p>))}
</div>

{/* 参考 URL */}
<div>
<Label>参考 URL</Label>
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>

{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
追加
</Button>
</Form>
</MainArea>)
}) satisfies FC

+ 49
- 0
frontend/src/pages/materials/MaterialSearchPage.tsx View File

@@ -0,0 +1,49 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'

import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'

import type { FC, FormEvent } from 'react'


export default (() => {
const [tagName, setTagName] = useState ('')
const [parentTagName, setParentTagName] = useState ('')

const handleSearch = (e: FormEvent) => {
e.preventDefault ()
}

return (
<MainArea>
<Helmet>
<title>素材集 | {SITE_TITLE}</title>
</Helmet>

<div className="max-w-xl">
<PageTitle>素材集</PageTitle>

<form onSubmit={handleSearch} className="space-y-2">
{/* タグ */}
<div>
<Label>タグ</Label>
<TagInput
value={tagName}
setValue={setTagName}/>
</div>

{/* 親タグ */}
<div>
<Label>親タグ</Label>
<TagInput
value={parentTagName}
setValue={setParentTagName}/>
</div>
</form>
</div>
</MainArea>)
}) satisfies FC

+ 1
- 1
frontend/src/pages/posts/PostDetailPage.tsx View File

@@ -93,7 +93,7 @@ export default (({ user }: Props) => {
: 'bg-gray-500 hover:bg-gray-600')

return (
<div className="md:flex md:flex-1">
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]">
<Helmet>
{(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}


+ 1
- 1
frontend/src/pages/posts/PostListPage.tsx View File

@@ -69,7 +69,7 @@ export default (() => {
}, [location.search])

return (
<div className="md:flex md:flex-1" ref={containerRef}>
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]" ref={containerRef}>
<Helmet>
<title>
{tags.length


+ 6
- 82
frontend/src/pages/posts/PostSearchPage.tsx View File

@@ -7,24 +7,22 @@ import { useLocation, useNavigate } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink'
import TagSearchBox from '@/components/TagSearchBox'
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { dateString, originalCreatedAtString } from '@/lib/utils'

import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
import type { FC, FormEvent } from 'react'

import type { FetchPostsOrder,
FetchPostsOrderField,
FetchPostsParams,
Tag } from '@/types'
FetchPostsParams } from '@/types'


const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
@@ -57,14 +55,11 @@ export default (() => {
const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder

const [activeIndex, setActiveIndex] = useState (-1)
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const [tagsStr, setTagsStr] = useState ('')
const [title, setTitle] = useState ('')
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
@@ -103,58 +98,6 @@ export default (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])

// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setTagsStr (ev.target.value)

const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}

const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break

case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break

case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
break

case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
}

const search = async () => {
const qs = new URLSearchParams ()
setIf (qs, 'tags', tagsStr)
@@ -172,15 +115,6 @@ export default (() => {
navigate (`${ location.pathname }?${ qs.toString () }`)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = tagsStr.split (' ')
parts[parts.length - 1] = tag.name
setTagsStr (parts.join (' ') + ' ')
setSuggestions ([])
setActiveIndex (-1)
}

const handleSearch = (e: FormEvent) => {
e.preventDefault ()
search ()
@@ -223,21 +157,11 @@ export default (() => {
</div>

{/* タグ */}
<div className="relative">
<div>
<Label>タグ</Label>
<input
type="text"
<TagInput
value={tagsStr}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
setValue={setTagsStr}/>
<fieldset className="w-full my-2">
<label>検索区分:</label>
<label className="mx-2">


+ 13
- 0
frontend/src/types.ts View File

@@ -49,6 +49,18 @@ export type FetchTagsParams = {
limit: number
order: FetchTagsOrder }

export type Material = {
id: number
tag: Tag
file: string | null
url: string | null
wikiPageBody?: string | null
contentType: string | null
createdAt: string
createdByUser: { id: number; name: string }
updatedAt: string
updatedByUser: { id: number; name: string } }

export type Menu = MenuItem[]

export type MenuItem = {
@@ -129,6 +141,7 @@ export type Tag = {
createdAt: string
updatedAt: string
hasWiki: boolean
materialId: number
children?: Tag[]
matchedAlias?: string | null }



Loading…
Cancel
Save