| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -127,17 +127,20 @@ class PostsController < ApplicationController | |||
| post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, | |||
| original_created_from:, original_created_before:) | |||
| post.thumbnail.attach(thumbnail) | |||
| if post.save | |||
| post.resized_thumbnail! | |||
| ActiveRecord::Base.transaction do | |||
| post.save! | |||
| tags = Tag.normalise_tags(tag_names) | |||
| tags = Tag.expand_parent_tags(tags) | |||
| sync_post_tags!(post, tags) | |||
| post.reload | |||
| render json: PostRepr.base(post), status: :created | |||
| else | |||
| render json: { errors: post.errors.full_messages }, status: :unprocessable_entity | |||
| post.resized_thumbnail! | |||
| PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) | |||
| end | |||
| post.reload | |||
| render json: PostRepr.base(post), status: :created | |||
| rescue ActiveRecord::RecordInvalid | |||
| render json: { errors: post.errors.full_messages }, status: :unprocessable_entity | |||
| rescue Tag::NicoTagNormalisationError | |||
| head :bad_request | |||
| end | |||
| @@ -166,19 +169,22 @@ class PostsController < ApplicationController | |||
| original_created_before = params[:original_created_before] | |||
| post = Post.find(params[:id].to_i) | |||
| if post.update(title:, original_created_from:, original_created_before:) | |||
| ActiveRecord::Base.transaction do | |||
| post.update!(title:, original_created_from:, original_created_before:) | |||
| tags = post.tags.where(category: 'nico').to_a + | |||
| Tag.normalise_tags(tag_names, with_tagme: false) | |||
| tags = Tag.expand_parent_tags(tags) | |||
| sync_post_tags!(post, tags) | |||
| post.reload | |||
| json = post.as_json | |||
| json['tags'] = build_tag_tree_for(post.tags) | |||
| render json:, status: :ok | |||
| else | |||
| render json: post.errors, status: :unprocessable_entity | |||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) | |||
| end | |||
| post.reload | |||
| json = post.as_json | |||
| json['tags'] = build_tag_tree_for(post.tags) | |||
| render json:, status: :ok | |||
| rescue ActiveRecord::RecordInvalid | |||
| render json: post.errors, status: :unprocessable_entity | |||
| rescue Tag::NicoTagNormalisationError | |||
| head :bad_request | |||
| end | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -11,6 +11,7 @@ class Post < ApplicationRecord | |||
| has_many :user_post_views, dependent: :delete_all | |||
| has_many :post_similarities, dependent: :delete_all | |||
| has_many :post_versions | |||
| has_one_attached :thumbnail | |||
| @@ -30,6 +31,8 @@ class Post < ApplicationRecord | |||
| super(options).merge(thumbnail: nil) | |||
| end | |||
| def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') | |||
| def related limit: nil | |||
| ids = post_similarities.order(cos: :desc) | |||
| ids = ids.limit(limit) if limit | |||
| @@ -0,0 +1,38 @@ | |||
| class PostVersion < ApplicationRecord | |||
| before_update do | |||
| raise ActiveRecord::ReadOnlyRecord, '版は更新できません.' | |||
| end | |||
| before_destroy do | |||
| raise ActiveRecord::ReadOnlyRecord, '版は削除できません.' | |||
| end | |||
| belongs_to :post | |||
| belongs_to :parent, class_name: 'Post', optional: true | |||
| belongs_to :created_by_user, class_name: 'User', optional: true | |||
| enum :event_type, { create: 'create', | |||
| update: 'update', | |||
| discard: 'discard', | |||
| restore: 'restore' }, prefix: true, validate: true | |||
| validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } | |||
| validates :event_type, presence: true, inclusion: { in: event_types.keys } | |||
| validates :url, presence: true | |||
| validate :validate_original_created_range | |||
| scope :chronological, -> { order(:version_no, :id) } | |||
| private | |||
| def validate_original_created_range | |||
| f = original_created_from | |||
| b = original_created_before | |||
| return if f.blank? || b.blank? | |||
| if f >= b | |||
| errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.' | |||
| end | |||
| end | |||
| end | |||
| @@ -1,3 +1,6 @@ | |||
| require 'set' | |||
| class Tag < ApplicationRecord | |||
| include MyDiscard | |||
| @@ -31,6 +34,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 +76,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 | |||
| @@ -147,6 +153,8 @@ class Tag < ApplicationRecord | |||
| def self.merge_tags! target_tag, source_tags | |||
| target_tag => Tag | |||
| affected_post_ids = Set.new | |||
| Tag.transaction do | |||
| Array(source_tags).compact.uniq.each do |source_tag| | |||
| source_tag => Tag | |||
| @@ -155,6 +163,7 @@ class Tag < ApplicationRecord | |||
| source_tag.post_tags.kept.find_each do |source_pt| | |||
| post_id = source_pt.post_id | |||
| affected_post_ids << post_id | |||
| source_pt.discard_by!(nil) | |||
| unless PostTag.kept.exists?(post_id:, tag: target_tag) | |||
| PostTag.create!(post_id:, tag: target_tag) | |||
| @@ -177,6 +186,10 @@ class Tag < ApplicationRecord | |||
| end | |||
| end | |||
| Post.where(id: affected_post_ids.to_a).find_each do |post| | |||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) | |||
| end | |||
| # 投稿件数を再集計 | |||
| target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count) | |||
| end | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -0,0 +1,57 @@ | |||
| class PostVersionRecorder | |||
| def self.record! post:, event_type:, created_by_user: | |||
| new(post:, event_type:, created_by_user:).record! | |||
| end | |||
| def initialize post:, event_type:, created_by_user: | |||
| @post = post | |||
| @event_type = event_type | |||
| @created_by_user = created_by_user | |||
| end | |||
| def record! | |||
| @post.with_lock do | |||
| latest = @post.post_versions.order(version_no: :desc).first | |||
| attrs = snapshot_attributes | |||
| return latest if @event_type == :update && latest && same_snapshot?(latest, attrs) | |||
| PostVersion.create!( | |||
| post: @post, | |||
| version_no: (latest&.version_no || 0) + 1, | |||
| event_type: @event_type, | |||
| title: attrs[:title], | |||
| url: attrs[:url], | |||
| thumbnail_base: attrs[:thumbnail_base], | |||
| tags: attrs[:tags], | |||
| parent: attrs[:parent], | |||
| original_created_from: attrs[:original_created_from], | |||
| original_created_before: attrs[:original_created_before], | |||
| created_at: Time.current, | |||
| created_by_user: @created_by_user) | |||
| end | |||
| end | |||
| private | |||
| def snapshot_attributes | |||
| { title: @post.title, | |||
| url: @post.url, | |||
| thumbnail_base: @post.thumbnail_base, | |||
| tags: @post.snapshot_tag_names.join(' '), | |||
| parent: @post.parent, | |||
| original_created_from: @post.original_created_from, | |||
| original_created_before: @post.original_created_before } | |||
| end | |||
| def same_snapshot? version, attrs | |||
| true && | |||
| version.title == attrs[:title] && | |||
| version.url == attrs[:url] && | |||
| version.thumbnail_base == attrs[:thumbnail_base] && | |||
| version.tags == attrs[:tags] && | |||
| version.parent_id == attrs[:parent]&.id && | |||
| version.original_created_from == attrs[:original_created_from] && | |||
| version.original_created_before == attrs[:original_created_before] | |||
| end | |||
| end | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -0,0 +1,203 @@ | |||
| require 'set' | |||
| class CreatePostVersions < ActiveRecord::Migration[8.0] | |||
| class Post < ApplicationRecord | |||
| self.table_name = 'posts' | |||
| end | |||
| class PostTag < ApplicationRecord | |||
| self.table_name = 'post_tags' | |||
| end | |||
| class PostVersion < ApplicationRecord | |||
| self.table_name = 'post_versions' | |||
| end | |||
| def up | |||
| create_table :post_versions do |t| | |||
| t.references :post, null: false, foreign_key: true | |||
| t.integer :version_no, null: false | |||
| t.string :event_type, null: false | |||
| t.string :title | |||
| t.string :url, limit: 768, null: false | |||
| t.string :thumbnail_base, limit: 2000 | |||
| t.text :tags, null: false | |||
| t.references :parent, foreign_key: { to_table: :posts } | |||
| t.datetime :original_created_from | |||
| t.datetime :original_created_before | |||
| t.datetime :created_at, null: false | |||
| t.references :created_by_user, foreign_key: { to_table: :users } | |||
| t.index [:post_id, :version_no], unique: true | |||
| t.check_constraint 'version_no > 0', | |||
| name: 'post_versions_version_no_positive' | |||
| t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')", | |||
| name: 'post_versions_event_type_valid' | |||
| end | |||
| PostVersion.reset_column_information | |||
| say_with_time 'Backfilling post_versions' do | |||
| Post.find_in_batches(batch_size: 500) do |posts| | |||
| post_ids = posts.map(&:id) | |||
| post_tag_rows_by_post_id = | |||
| PostTag | |||
| .joins('INNER JOIN tags ON tags.id = post_tags.tag_id') | |||
| .joins('INNER JOIN tag_names ON tag_names.id = tags.tag_name_id') | |||
| .where(post_id: post_ids) | |||
| .pluck('post_tags.post_id', | |||
| 'post_tags.created_at', | |||
| 'post_tags.discarded_at', | |||
| 'post_tags.created_user_id', | |||
| 'post_tags.deleted_user_id', | |||
| 'tag_names.name') | |||
| .each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h| | |||
| post_id, created_at, discarded_at, created_user_id, deleted_user_id, tag_name = row | |||
| h[post_id] << { created_at:, | |||
| discarded_at:, | |||
| created_user_id:, | |||
| deleted_user_id:, | |||
| tag_name: } | |||
| end | |||
| rows = [] | |||
| posts.each do |post| | |||
| post_tag_rows = post_tag_rows_by_post_id[post.id] | |||
| events = post_tag_rows.flat_map do |post_tag_row| | |||
| ary = [[post_tag_row[:created_at], | |||
| post_tag_row[:created_user_id], | |||
| :add, | |||
| post_tag_row[:tag_name]]] | |||
| if post_tag_row[:discarded_at] | |||
| ary << [post_tag_row[:discarded_at], | |||
| post_tag_row[:deleted_user_id], | |||
| :remove, | |||
| post_tag_row[:tag_name]] | |||
| end | |||
| ary | |||
| end | |||
| kind_order = { add: 0, remove: 1 } | |||
| events.sort_by! do |event_at, user_id, kind, tag_name| | |||
| [event_at, user_id || 0, kind_order.fetch(kind), tag_name] | |||
| end | |||
| event_buckets = bucket_events(events) | |||
| active_tags = Set.new | |||
| version_no = 0 | |||
| if event_buckets.empty? | |||
| version_no += 1 | |||
| rows << build_row(post:, | |||
| version_no:, | |||
| event_type: 'create', | |||
| created_at: post.created_at, | |||
| created_by_user_id: post.uploaded_user_id, | |||
| tags: []) | |||
| next | |||
| end | |||
| first_bucket = event_buckets.first | |||
| merge_first_bucket_into_create = first_bucket[:first_at] <= post.created_at + 1.second | |||
| if merge_first_bucket_into_create | |||
| event_buckets.shift | |||
| apply_bucket!(active_tags, first_bucket) | |||
| version_no += 1 | |||
| rows << build_row( | |||
| post:, | |||
| version_no:, | |||
| event_type: 'create', | |||
| created_at: post.created_at, | |||
| created_by_user_id: post.uploaded_user_id || first_bucket[:user_ids].compact.first, | |||
| tags: active_tags.to_a.sort) | |||
| else | |||
| version_no += 1 | |||
| rows << build_row( | |||
| post:, | |||
| version_no:, | |||
| event_type: 'create', | |||
| created_at: post.created_at, | |||
| created_by_user_id: post.uploaded_user_id, | |||
| tags: []) | |||
| end | |||
| event_buckets.each do |bucket| | |||
| apply_bucket!(active_tags, bucket) | |||
| version_no += 1 | |||
| rows << build_row( | |||
| post:, | |||
| version_no:, | |||
| event_type: 'update', | |||
| created_at: bucket[:first_at], | |||
| created_by_user_id: bucket[:user_ids].compact.first, | |||
| tags: active_tags.to_a.sort) | |||
| end | |||
| end | |||
| PostVersion.insert_all!(rows) if rows.any? | |||
| end | |||
| end | |||
| end | |||
| def down | |||
| drop_table :post_versions | |||
| end | |||
| private | |||
| def bucket_events events | |||
| buckets = [] | |||
| events.each do |event_at, user_id, kind, tag_name| | |||
| if buckets.empty? || event_at - buckets.last[:last_at] > 1.second | |||
| buckets << { first_at: event_at, | |||
| last_at: event_at, | |||
| user_ids: [user_id], | |||
| events: [[kind, tag_name]] } | |||
| else | |||
| bucket = buckets.last | |||
| bucket[:last_at] = event_at | |||
| bucket[:user_ids] << user_id | |||
| bucket[:events] << [kind, tag_name] | |||
| end | |||
| end | |||
| buckets | |||
| end | |||
| def apply_bucket! active_tags, bucket | |||
| bucket[:events].each do |kind, tag_name| | |||
| if kind == :add | |||
| active_tags.add(tag_name) | |||
| else | |||
| active_tags.delete(tag_name) | |||
| end | |||
| end | |||
| end | |||
| def build_row post:, version_no:, event_type:, created_at:, created_by_user_id:, tags: | |||
| { post_id: post.id, | |||
| version_no:, | |||
| event_type:, | |||
| title: post.title, | |||
| url: post.url, | |||
| thumbnail_base: post.thumbnail_base, | |||
| tags: tags.join(' '), | |||
| parent_id: post.parent_id, | |||
| original_created_from: post.original_created_from, | |||
| original_created_before: post.original_created_before, | |||
| created_at:, | |||
| created_by_user_id: } | |||
| end | |||
| end | |||
| @@ -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_04_09_123700) 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 | |||
| @@ -93,6 +132,27 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||
| t.index ["tag_id"], name: "index_post_tags_on_tag_id" | |||
| end | |||
| create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "post_id", null: false | |||
| t.integer "version_no", null: false | |||
| t.string "event_type", null: false | |||
| t.string "title" | |||
| t.string "url", limit: 768, null: false | |||
| t.string "thumbnail_base", limit: 2000 | |||
| t.text "tags", null: false | |||
| t.bigint "parent_id" | |||
| t.datetime "original_created_from" | |||
| t.datetime "original_created_before" | |||
| t.datetime "created_at", null: false | |||
| t.bigint "created_by_user_id" | |||
| t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id" | |||
| t.index ["parent_id"], name: "index_post_versions_on_parent_id" | |||
| t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true | |||
| t.index ["post_id"], name: "index_post_versions_on_post_id" | |||
| t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" | |||
| t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" | |||
| end | |||
| create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.string "title" | |||
| t.string "url", limit: 768, null: false | |||
| @@ -239,6 +299,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 +327,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 +366,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" | |||
| @@ -300,6 +383,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||
| add_foreign_key "post_tags", "tags" | |||
| add_foreign_key "post_tags", "users", column: "created_user_id" | |||
| add_foreign_key "post_tags", "users", column: "deleted_user_id" | |||
| add_foreign_key "post_versions", "posts" | |||
| add_foreign_key "post_versions", "posts", column: "parent_id" | |||
| add_foreign_key "post_versions", "users", column: "created_by_user_id" | |||
| add_foreign_key "posts", "posts", column: "parent_id" | |||
| add_foreign_key "posts", "users", column: "uploaded_user_id" | |||
| add_foreign_key "settings", "users" | |||
| @@ -320,6 +406,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" | |||
| @@ -61,6 +61,9 @@ namespace :nico do | |||
| original_created_from = original_created_at&.change(sec: 0) | |||
| original_created_before = original_created_from&.+(1.minute) | |||
| post_created = false | |||
| post_changed = false | |||
| if post | |||
| attrs = { title:, original_created_from:, original_created_before: } | |||
| @@ -76,11 +79,13 @@ namespace :nico do | |||
| end | |||
| post.assign_attributes(attrs) | |||
| if post.changed? | |||
| post_changed = post.changed? | |||
| if post_changed | |||
| post.save! | |||
| post.resized_thumbnail! if post.thumbnail.attached? | |||
| end | |||
| else | |||
| post_created = true | |||
| url = "https://www.nicovideo.jp/watch/#{ code }" | |||
| thumbnail_base = fetch_thumbnail.(url) rescue nil | |||
| post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil, | |||
| @@ -140,6 +145,12 @@ namespace :nico do | |||
| desired_all_tag_ids.uniq! | |||
| sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids) | |||
| if post_created | |||
| PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) | |||
| elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set | |||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) | |||
| end | |||
| end | |||
| end | |||
| end | |||
| @@ -0,0 +1,41 @@ | |||
| require 'rails_helper' | |||
| RSpec.describe PostVersion, type: :model do | |||
| let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') } | |||
| let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } | |||
| let!(:post_record) do | |||
| Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post| | |||
| PostTag.create!(post: post, tag: tag) | |||
| end | |||
| end | |||
| let!(:post_version) do | |||
| PostVersion.create!( | |||
| post: post_record, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| title: post_record.title, | |||
| url: post_record.url, | |||
| thumbnail_base: post_record.thumbnail_base, | |||
| tags: post_record.snapshot_tag_names.join(' '), | |||
| parent: post_record.parent, | |||
| original_created_from: post_record.original_created_from, | |||
| original_created_before: post_record.original_created_before, | |||
| created_at: Time.current, | |||
| created_by_user: nil | |||
| ) | |||
| end | |||
| it 'is read only after create' do | |||
| expect do | |||
| post_version.update!(title: 'changed') | |||
| end.to raise_error(ActiveRecord::ReadOnlyRecord) | |||
| end | |||
| it 'cannot be destroyed' do | |||
| expect do | |||
| post_version.destroy! | |||
| end.to raise_error(ActiveRecord::ReadOnlyRecord) | |||
| end | |||
| end | |||
| @@ -145,5 +145,70 @@ RSpec.describe Tag, type: :model do | |||
| expect(target_tag.reload.post_count).to eq(0) | |||
| end | |||
| end | |||
| def snapshot_tags(post) | |||
| post.snapshot_tag_names.join(' ') | |||
| end | |||
| def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil) | |||
| PostVersion.create!( | |||
| post: post, | |||
| version_no: version_no, | |||
| event_type: event_type, | |||
| title: post.title, | |||
| url: post.url, | |||
| thumbnail_base: post.thumbnail_base, | |||
| tags: snapshot_tags(post), | |||
| parent: post.parent, | |||
| original_created_from: post.original_created_from, | |||
| original_created_before: post.original_created_before, | |||
| created_at: Time.current, | |||
| created_by_user: created_by_user | |||
| ) | |||
| end | |||
| context 'when post versions are enabled' do | |||
| let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } | |||
| let!(:unaffected_post) do | |||
| Post.create!(url: 'https://example.com/posts/2', title: 'unaffected post') | |||
| end | |||
| before do | |||
| create_post_version_for!(post_record) | |||
| create_post_version_for!(unaffected_post) | |||
| end | |||
| it 'creates an update post_version only for affected posts' do | |||
| expect { | |||
| described_class.merge_tags!(target_tag, [source_tag]) | |||
| }.to change(PostVersion, :count).by(1) | |||
| affected_versions = post_record.reload.post_versions.order(:version_no) | |||
| expect(affected_versions.pluck(:version_no)).to eq([1, 2]) | |||
| latest = affected_versions.last | |||
| expect(latest.event_type).to eq('update') | |||
| expect(latest.created_by_user).to be_nil | |||
| expect(latest.tags).to eq(snapshot_tags(post_record.reload)) | |||
| expect(unaffected_post.reload.post_versions.count).to eq(1) | |||
| end | |||
| end | |||
| context 'when the source tag has no active post_tags' do | |||
| let!(:another_post) do | |||
| Post.create!(url: 'https://example.com/posts/3', title: 'another post') | |||
| end | |||
| before do | |||
| create_post_version_for!(another_post) | |||
| end | |||
| it 'does not create any post_version' do | |||
| expect { | |||
| described_class.merge_tags!(target_tag, [source_tag]) | |||
| }.not_to change(PostVersion, :count) | |||
| end | |||
| end | |||
| end | |||
| end | |||
| @@ -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 | |||
| @@ -795,4 +795,127 @@ RSpec.describe 'Posts API', type: :request do | |||
| expect(user.reload.viewed?(post_record)).to be(false) | |||
| end | |||
| end | |||
| describe 'post versioning' do | |||
| let(:member) { create(:user, :member) } | |||
| def snapshot_tags(post) | |||
| post.snapshot_tag_names.join(' ') | |||
| end | |||
| def create_post_version_for!(post) | |||
| PostVersion.create!( | |||
| post: post, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| title: post.title, | |||
| url: post.url, | |||
| thumbnail_base: post.thumbnail_base, | |||
| tags: snapshot_tags(post), | |||
| parent: post.parent, | |||
| original_created_from: post.original_created_from, | |||
| original_created_before: post.original_created_before, | |||
| created_at: post.created_at, | |||
| created_by_user: post.uploaded_user | |||
| ) | |||
| end | |||
| it 'creates version 1 on POST /posts' do | |||
| sign_in_as(member) | |||
| expect do | |||
| post '/posts', params: { | |||
| title: 'versioned post', | |||
| url: 'https://example.com/versioned-post', | |||
| tags: 'spec_tag', | |||
| thumbnail: dummy_upload | |||
| } | |||
| end.to change(PostVersion, :count).by(1) | |||
| expect(response).to have_http_status(:created) | |||
| created_post = Post.find(json.fetch('id')) | |||
| version = PostVersion.find_by!(post: created_post, version_no: 1) | |||
| expect(version.event_type).to eq('create') | |||
| expect(version.title).to eq('versioned post') | |||
| expect(version.url).to eq('https://example.com/versioned-post') | |||
| expect(version.created_by_user_id).to eq(member.id) | |||
| expect(version.tags).to eq(snapshot_tags(created_post)) | |||
| end | |||
| it 'creates next version on PUT /posts/:id when snapshot changes' do | |||
| sign_in_as(member) | |||
| create_post_version_for!(post_record) | |||
| tag_name2 = TagName.create!(name: 'spec_tag_2') | |||
| Tag.create!(tag_name: tag_name2, category: :general) | |||
| expect do | |||
| put "/posts/#{post_record.id}", params: { | |||
| title: 'updated title', | |||
| tags: 'spec_tag_2' | |||
| } | |||
| end.to change(PostVersion, :count).by(1) | |||
| expect(response).to have_http_status(:ok) | |||
| version = post_record.reload.post_versions.order(:version_no).last | |||
| expect(version.version_no).to eq(2) | |||
| expect(version.event_type).to eq('update') | |||
| expect(version.title).to eq('updated title') | |||
| expect(version.created_by_user_id).to eq(member.id) | |||
| expect(version.tags).to eq(snapshot_tags(post_record.reload)) | |||
| end | |||
| it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do | |||
| sign_in_as(member) | |||
| create_post_version_for!(post_record) | |||
| expect do | |||
| put "/posts/#{post_record.id}", params: { | |||
| title: post_record.title, | |||
| tags: 'spec_tag' | |||
| } | |||
| end.not_to change(PostVersion, :count) | |||
| expect(response).to have_http_status(:ok) | |||
| version = post_record.reload.post_versions.order(:version_no).last | |||
| expect(version.version_no).to eq(1) | |||
| expect(version.event_type).to eq('create') | |||
| expect(version.tags).to eq(snapshot_tags(post_record)) | |||
| end | |||
| it 'does not create a version when POST /posts is invalid' do | |||
| sign_in_as(member) | |||
| expect do | |||
| post '/posts', params: { | |||
| title: 'invalid post', | |||
| url: 'ぼざクリタグ広場', | |||
| tags: 'spec_tag', | |||
| thumbnail: dummy_upload | |||
| } | |||
| end.not_to change(PostVersion, :count) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| end | |||
| it 'does not create a version when PUT /posts/:id is invalid' do | |||
| sign_in_as(member) | |||
| create_post_version_for!(post_record) | |||
| expect do | |||
| put "/posts/#{post_record.id}", params: { | |||
| title: 'updated title', | |||
| tags: 'spec_tag', | |||
| original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601, | |||
| original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601 | |||
| } | |||
| end.not_to change(PostVersion, :count) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| end | |||
| end | |||
| end | |||
| @@ -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 | |||
| @@ -90,4 +90,128 @@ RSpec.describe "nico:sync" do | |||
| expect(active_names).to include("nico:NEW") | |||
| expect(active_names).not_to include("nico:OLD") | |||
| end | |||
| def snapshot_tags(post) | |||
| post.snapshot_tag_names.join(' ') | |||
| end | |||
| def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil) | |||
| PostVersion.create!( | |||
| post: post, | |||
| version_no: version_no, | |||
| event_type: event_type, | |||
| title: post.title, | |||
| url: post.url, | |||
| thumbnail_base: post.thumbnail_base, | |||
| tags: snapshot_tags(post), | |||
| parent: post.parent, | |||
| original_created_from: post.original_created_from, | |||
| original_created_before: post.original_created_before, | |||
| created_at: Time.current, | |||
| created_by_user: created_by_user | |||
| ) | |||
| end | |||
| it '新規 post 作成時に version 1 を作る' do | |||
| Tag.bot | |||
| Tag.tagme | |||
| Tag.niconico | |||
| Tag.video | |||
| Tag.no_deerjikist | |||
| stub_python([{ | |||
| 'code' => 'sm9', | |||
| 'title' => 't', | |||
| 'tags' => ['AAA'], | |||
| 'uploaded_at' => '2026-01-01 12:34:56' | |||
| }]) | |||
| allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) | |||
| expect { | |||
| run_rake_task('nico:sync') | |||
| }.to change(PostVersion, :count).by(1) | |||
| post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9') | |||
| version = post.post_versions.order(:version_no).last | |||
| expect(version.version_no).to eq(1) | |||
| expect(version.event_type).to eq('create') | |||
| expect(version.created_by_user).to be_nil | |||
| expect(version.tags).to eq(snapshot_tags(post.reload)) | |||
| end | |||
| it '既存 post の内容または tags が変わったとき update version を作る' do | |||
| post = Post.create!( | |||
| title: 'old', | |||
| url: 'https://www.nicovideo.jp/watch/sm9', | |||
| uploaded_user: nil | |||
| ) | |||
| kept_general = create_tag!('spec_kept', category: 'general') | |||
| PostTag.create!(post: post, tag: kept_general) | |||
| create_post_version_for!(post) | |||
| linked = create_tag!('spec_linked', category: 'general') | |||
| nico = create_tag!('nico:AAA', category: 'nico') | |||
| link_nico_to_tag!(nico, linked) | |||
| Tag.bot | |||
| Tag.tagme | |||
| Tag.no_deerjikist | |||
| stub_python([{ | |||
| 'code' => 'sm9', | |||
| 'title' => 't', | |||
| 'tags' => ['AAA'], | |||
| 'uploaded_at' => '2026-01-01 12:34:56' | |||
| }]) | |||
| allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) | |||
| expect { | |||
| run_rake_task('nico:sync') | |||
| }.to change(PostVersion, :count).by(1) | |||
| version = post.reload.post_versions.order(:version_no).last | |||
| expect(version.version_no).to eq(2) | |||
| expect(version.event_type).to eq('update') | |||
| expect(version.created_by_user).to be_nil | |||
| expect(version.tags).to eq(snapshot_tags(post.reload)) | |||
| end | |||
| it '既存 post に差分が無いときは新しい version を作らない' do | |||
| nico = create_tag!('nico:AAA', category: 'nico') | |||
| no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta') | |||
| post = Post.create!( | |||
| title: 't', | |||
| url: 'https://www.nicovideo.jp/watch/sm9', | |||
| uploaded_user: nil, | |||
| original_created_from: Time.iso8601('2026-01-01T03:34:00Z'), | |||
| original_created_before: Time.iso8601('2026-01-01T03:35:00Z') | |||
| ) | |||
| PostTag.create!(post: post, tag: nico) | |||
| PostTag.create!(post: post, tag: no_deerjikist) | |||
| create_post_version_for!(post) | |||
| stub_python([{ | |||
| 'code' => 'sm9', | |||
| 'title' => 't', | |||
| 'tags' => ['AAA'], | |||
| 'uploaded_at' => '2026-01-01 12:34:56' | |||
| }]) | |||
| allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) | |||
| expect { | |||
| run_rake_task('nico:sync') | |||
| }.not_to change(PostVersion, :count) | |||
| version = post.reload.post_versions.order(:version_no).last | |||
| expect(version.version_no).to eq(1) | |||
| expect(version.event_type).to eq('create') | |||
| expect(version.tags).to eq(snapshot_tags(post.reload)) | |||
| end | |||
| end | |||
| @@ -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 TOSPage from '@/pages/TOSPage.mdx' | |||
| @@ -42,7 +47,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}/>}/> | |||
| @@ -52,6 +57,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}/>}/> | |||
| @@ -122,7 +133,7 @@ export default (() => { | |||
| <> | |||
| <RouteBlockerOverlay/> | |||
| <BrowserRouter> | |||
| <div className="flex flex-col h-screen w-screen"> | |||
| <div className="flex flex-col h-dvh w-full"> | |||
| <TopNav user={user}/> | |||
| <RouteTransitionWrapper user={user} setUser={setUser}/> | |||
| </div> | |||
| @@ -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] ? <>−</> : '+'} | |||
| </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 | |||
| @@ -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}> | |||
| @@ -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> | |||
| @@ -74,20 +74,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', visible: false }, | |||
| // { name: '追加', to: '/materials/new' }, | |||
| // { name: '履歴', to: '/materials/changes', visible: false }, | |||
| // { 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' }, | |||
| @@ -139,9 +146,9 @@ export default (({ user }: Props) => { | |||
| return ( | |||
| <> | |||
| <nav className="px-3 flex justify-between items-center w-full min-h-[48px] | |||
| <nav className="px-3 flex justify-between items-center w-full | |||
| bg-yellow-200 dark:bg-red-975 md:bg-yellow-50"> | |||
| <div className="flex items-center gap-2 h-full"> | |||
| <div className="flex items-center gap-2 h-12"> | |||
| <PrefetchLink | |||
| to="/posts" | |||
| className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400 | |||
| @@ -152,13 +159,13 @@ export default (({ user }: Props) => { | |||
| ぼざクリ タグ広場 | |||
| </PrefetchLink> | |||
| <div ref={navRef} className="relative hidden md:flex h-full items-center"> | |||
| <div ref={navRef} className="relative hidden md:flex h-12 items-center"> | |||
| <div aria-hidden | |||
| className={cn ('absolute top-1/2 -translate-y-1/2 h-full', | |||
| className={cn ('absolute inset-y-0 h-12', | |||
| 'bg-yellow-200 dark:bg-red-950', | |||
| 'transition-[transform,width] duration-200 ease-out')} | |||
| style={{ width: hl.width, | |||
| transform: `translate(${ hl.left }px, -50%)`, | |||
| transform: `translateX(${ hl.left }px)`, | |||
| opacity: hl.visible ? 1 : 0 }}/> | |||
| {menu.map ((item, i) => ( | |||
| @@ -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> | |||
| @@ -8,6 +8,7 @@ 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)]', | |||
| className)}> | |||
| {children} | |||
| </main>)) satisfies FC<Props> | |||
| @@ -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> | |||
| @@ -46,10 +46,12 @@ a | |||
| body | |||
| { | |||
| margin: 0; | |||
| display: flex; | |||
| place-items: center; | |||
| min-width: 320px; | |||
| min-height: 100vh; | |||
| } | |||
| #root | |||
| { | |||
| min-height: 100dvh; | |||
| } | |||
| h1 | |||
| @@ -0,0 +1,13 @@ | |||
| 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 overflow-y-auto md:overflow-y-hidden | |||
| md:h-[calc(100dvh-88px)]"> | |||
| <MaterialSidebar/> | |||
| <Outlet/> | |||
| </div>)) satisfies FC | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -93,7 +93,8 @@ 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 overflow-y-auto md:overflow-y-hidden | |||
| md:h-[calc(100dvh-88px)]"> | |||
| <Helmet> | |||
| {(post?.thumbnail || post?.thumbnailBase) && ( | |||
| <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} | |||
| @@ -69,7 +69,10 @@ export default (() => { | |||
| }, [location.search]) | |||
| return ( | |||
| <div className="md:flex md:flex-1" ref={containerRef}> | |||
| <div | |||
| className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden | |||
| md:h-[calc(100dvh-88px)]" | |||
| ref={containerRef}> | |||
| <Helmet> | |||
| <title> | |||
| {tags.length | |||
| @@ -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"> | |||
| @@ -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 } | |||