From c28326b941581700585bff4205d91a77c7396acf Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 29 Mar 2026 21:27:59 +0900 Subject: [PATCH 01/10] #99 --- backend/Gemfile | 4 +- backend/Gemfile.lock | 21 +++++ .../app/controllers/materials_controller.rb | 94 +++++++++++++++++++ backend/app/models/material.rb | 31 ++++++ backend/app/representations/material_repr.rb | 17 ++++ backend/config/environments/production.rb | 2 +- backend/config/routes.rb | 2 + backend/config/storage.yml | 33 ++----- .../20260329034700_create_materials.rb | 34 +++++++ backend/db/schema.rb | 66 ++++++++++++- 10 files changed, 274 insertions(+), 30 deletions(-) create mode 100644 backend/app/controllers/materials_controller.rb create mode 100644 backend/app/models/material.rb create mode 100644 backend/app/representations/material_repr.rb create mode 100644 backend/db/migrate/20260329034700_create_materials.rb diff --git a/backend/Gemfile b/backend/Gemfile index 1d48493..2d0a90c 100644 --- a/backend/Gemfile +++ b/backend/Gemfile @@ -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 diff --git a/backend/Gemfile.lock b/backend/Gemfile.lock index 42eb862..f9dc02c 100644 --- a/backend/Gemfile.lock +++ b/backend/Gemfile.lock @@ -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 diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb new file mode 100644 index 0000000..fd5e9b8 --- /dev/null +++ b/backend/app/controllers/materials_controller.rb @@ -0,0 +1,94 @@ +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: materials.map { |m| material_json(m) }, count: count } + end + + def show + material = Material.includes(:tag, :created_by_user).with_attached_file.find_by(id: params[:id]) + return head :not_found unless material + + render json: material_json(material) + end + + def create + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + file = params[:file] + return head :bad_request if file.blank? + + material = Material.new( + url: params[:url].presence, + parent_id: params[:parent_id].presence, + tag_id: params[:tag_id].presence, + created_by_user: current_user) + material.file.attach(file) + + if material.save + render json: material_json(material), 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 + + material.assign_attributes( + url: params[:url].presence, + parent_id: params[:parent_id].presence, + tag_id: params[:tag_id].presence + ) + material.file.attach(params[:file]) if params[:file].present? + + if material.save + render json: material_json(material) + 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 + + private + + def material_json(material) + MaterialRepr.base(material).merge( + 'filename' => material.file.attached? ? material.file.filename.to_s : nil, + 'byte_size' => material.file.attached? ? material.file.byte_size : nil, + 'content_type' => material.file.attached? ? material.file.content_type : nil, + 'url' => material.file.attached? ? url_for(material.file) : nil + ) + end +end diff --git a/backend/app/models/material.rb b/backend/app/models/material.rb new file mode 100644 index 0000000..37af665 --- /dev/null +++ b/backend/app/models/material.rb @@ -0,0 +1,31 @@ +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 + + validate :file_must_be_attached + validate :tag_must_be_material_category + + 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.material? + + errors.add(:tag, '素材カテゴリのタグを指定してください.') + end +end diff --git a/backend/app/representations/material_repr.rb b/backend/app/representations/material_repr.rb new file mode 100644 index 0000000..ed76450 --- /dev/null +++ b/backend/app/representations/material_repr.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + + +module MaterialRepr + BASE = { only: [:id, :url, :parent_id, :created_at, :updated_at], + include: { created_by_user: UserRepr::BASE, tag: TagRepr::BASE } }.freeze + + module_function + + def base(material) + material.as_json(BASE) + end + + def many(materials) + materials.map { |m| base(m) } + end +end diff --git a/backend/config/environments/production.rb b/backend/config/environments/production.rb index 0bd58c3..8477b2d 100644 --- a/backend/config/environments/production.rb +++ b/backend/config/environments/production.rb @@ -19,7 +19,7 @@ Rails.application.configure do # 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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index b9db110..e2d098b 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -81,4 +81,6 @@ Rails.application.routes.draw do resources :comments, controller: :theatre_comments, only: [:index, :create] end + + resources :materials, only: [:index, :show, :create, :update, :destroy] end diff --git a/backend/config/storage.yml b/backend/config/storage.yml index 4942ab6..c2c2a46 100644 --- a/backend/config/storage.yml +++ b/backend/config/storage.yml @@ -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 diff --git a/backend/db/migrate/20260329034700_create_materials.rb b/backend/db/migrate/20260329034700_create_materials.rb new file mode 100644 index 0000000..ecb1c07 --- /dev/null +++ b/backend/db/migrate/20260329034700_create_materials.rb @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 6a2096b..22541e2 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -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" -- 2.34.1 From 6ed7f81151ab77a7c6043da9b5445312b2632bad Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 2 Apr 2026 01:43:39 +0900 Subject: [PATCH 02/10] #99 --- backend/app/controllers/tags_controller.rb | 34 ++++++- backend/config/routes.rb | 1 + frontend/src/App.tsx | 4 + frontend/src/components/MaterialSidebar.tsx | 70 +++++++++++++ frontend/src/components/TagLink.tsx | 23 ++--- frontend/src/components/TagSidebar.tsx | 2 +- frontend/src/components/TopNav.tsx | 13 ++- frontend/src/components/common/TagInput.tsx | 97 +++++++++++++++++++ .../pages/materials/MaterialSearchPage.tsx | 49 ++++++++++ frontend/src/pages/posts/PostSearchPage.tsx | 88 ++--------------- 10 files changed, 278 insertions(+), 103 deletions(-) create mode 100644 frontend/src/components/MaterialSidebar.tsx create mode 100644 frontend/src/components/common/TagInput.tsx create mode 100644 frontend/src/pages/materials/MaterialSearchPage.tsx diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 9652f18..7e04e0e 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -37,7 +37,7 @@ class TagsController < ApplicationController 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,38 @@ 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, tag_name: :wiki_page) + .where(id: tag_ids) + .order('tag_names.name') + .distinct + .to_a + + has_children_tag_ids = + if tags.empty? + [] + else + TagImplication.where(parent_tag_id: tags.map(&:id)).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, '') diff --git a/backend/config/routes.rb b/backend/config/routes.rb index e2d098b..f724054 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -9,6 +9,7 @@ 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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8966a4..668de02 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,8 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' +import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' +// import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' import PostDetailPage from '@/pages/posts/PostDetailPage' @@ -51,6 +53,8 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> + {/* }/> */} }/> }/> }/> diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx new file mode 100644 index 0000000..8494eb4 --- /dev/null +++ b/frontend/src/components/MaterialSidebar.tsx @@ -0,0 +1,70 @@ +import { 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[] } + + +export default (() => { + const [tags, setTags] = useState ([]) + const [openTags, setOpenTags] = useState> ({ }) + const [tagFetchedFlags, setTagFetchedFlags] = useState> ({ }) + + const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => ( + <> + {ts.map (t => ( + <> +
  • + { + e.preventDefault () + if (!(tagFetchedFlags[t.id])) + { + try + { + const data = + await apiGet ( + '/tags/with-depth', { params: { parent: t.id } }) + setTags (prev => { + const rtn = structuredClone (prev) + rtn.find (x => x.id === t.id)!.children = data + return rtn + }) + setTagFetchedFlags (prev => ({ ...prev, [t.id]: true })) + } + catch + { + ; + } + } + setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) })) + }}> + {openTags[t.id] ? '-' : '+'} + + +
  • + {openTags[t.id] && renderTags (t.children, nestLevel + 1)} + ))} + ) + + return ( + +
      + {renderTags (tags)} +
    +
    ) +}) satisfies FC diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index b3a926c..5755ffe 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -13,8 +13,7 @@ type CommonProps = { tag: Tag nestLevel?: number withWiki?: boolean - withCount?: boolean - prefetch?: boolean } + withCount?: boolean } type PropsWithLink = & CommonProps @@ -36,7 +35,6 @@ export default (({ tag, linkFlg = true, withWiki = true, withCount = true, - prefetch = false, ...props }: Props) => { const [havingWiki, setHavingWiki] = useState (true) @@ -108,19 +106,12 @@ export default (({ tag, )} {linkFlg ? ( - prefetch - ? - {tag.name} - - : - {tag.name} - ) + + {tag.name} + ) : ( diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index d0bf5cc..7fbdfa3 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -66,7 +66,7 @@ export default (({ posts, onClick }: Props) => { tags[cat].map (tag => (
  • - +
  • ))) : [])} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 7d1575c..4686e5a 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -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: <>第 1 会場, to: '/theatres/1' }, { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' }, { name: <>ニジカ放送局第 1 チャンネル, - 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' }, diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx new file mode 100644 index 0000000..87b7238 --- /dev/null +++ b/frontend/src/components/common/TagInput.tsx @@ -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 ([]) + const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) + + // TODO: TagSearch からのコピペのため,共通化を考へる. + const whenChanged = async (ev: ChangeEvent) => { + setValue (ev.target.value) + + const q = ev.target.value.trim ().split (' ').at (-1) + if (!(q)) + { + setSuggestions ([]) + return + } + + const data = await apiGet ('/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) => { + 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 ( +
    + setSuggestionsVsbl (true)} + onBlur={() => setSuggestionsVsbl (false)} + onKeyDown={handleKeyDown} + className="w-full border p-2 rounded"/> + 0 ? suggestions : [] as Tag[]} + activeIndex={activeIndex} + onSelect={handleTagSelect}/> +
    ) +}) satisfies FC diff --git a/frontend/src/pages/materials/MaterialSearchPage.tsx b/frontend/src/pages/materials/MaterialSearchPage.tsx new file mode 100644 index 0000000..5882b40 --- /dev/null +++ b/frontend/src/pages/materials/MaterialSearchPage.tsx @@ -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 ( + + + 素材集 | {SITE_TITLE} + + +
    + 素材集 + +
    + {/* タグ */} +
    + + +
    + + {/* 親タグ */} +
    + + +
    +
    +
    +
    ) +}) satisfies FC diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index a824953..73071cc 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -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 (null) const [createdTo, setCreatedTo] = useState (null) const [matchType, setMatchType] = useState<'all' | 'any'> ('all') const [originalCreatedFrom, setOriginalCreatedFrom] = useState (null) const [originalCreatedTo, setOriginalCreatedTo] = useState (null) - const [suggestions, setSuggestions] = useState ([]) - const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const [tagsStr, setTagsStr] = useState ('') const [title, setTitle] = useState ('') const [updatedFrom, setUpdatedFrom] = useState (null) @@ -103,58 +98,6 @@ export default (() => { document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) }, [location.search]) - // TODO: TagSearch からのコピペのため,共通化を考へる. - const whenChanged = async (ev: ChangeEvent) => { - setTagsStr (ev.target.value) - - const q = ev.target.value.trim ().split (' ').at (-1) - if (!(q)) - { - setSuggestions ([]) - return - } - - const data = await apiGet ('/tags/autocomplete', { params: { q } }) - setSuggestions (data.filter (t => t.postCount > 0)) - if (suggestions.length > 0) - setSuggestionsVsbl (true) - } - - // TODO: TagSearch からのコピペのため,共通化を考へる. - const handleKeyDown = (ev: KeyboardEvent) => { - 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 (() => { {/* タグ */} -
    +
    - setSuggestionsVsbl (true)} - onBlur={() => setSuggestionsVsbl (false)} - onKeyDown={handleKeyDown} - className="w-full border p-2 rounded"/> - 0 ? suggestions : [] as Tag[]} - activeIndex={activeIndex} - onSelect={handleTagSelect}/> + setValue={setTagsStr}/>