Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea93556952 | |||
| 584415edff | |||
| 27989f3bb2 | |||
| 0c805671a0 | |||
| 2a4def667c | |||
| 9963050546 | |||
| a3914fb22a | |||
| c36b2c8a1b | |||
| e021423904 | |||
| 7b15cb2c5a | |||
| 09ff99309f | |||
| e09964818f | |||
| 39036d1189 |
@@ -127,17 +127,20 @@ class PostsController < ApplicationController
|
|||||||
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
|
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
|
||||||
original_created_from:, original_created_before:)
|
original_created_from:, original_created_before:)
|
||||||
post.thumbnail.attach(thumbnail)
|
post.thumbnail.attach(thumbnail)
|
||||||
if post.save
|
|
||||||
post.resized_thumbnail!
|
ActiveRecord::Base.transaction do
|
||||||
|
post.save!
|
||||||
tags = Tag.normalise_tags(tag_names)
|
tags = Tag.normalise_tags(tag_names)
|
||||||
tags = Tag.expand_parent_tags(tags)
|
tags = Tag.expand_parent_tags(tags)
|
||||||
sync_post_tags!(post, tags)
|
sync_post_tags!(post, tags)
|
||||||
|
post.resized_thumbnail!
|
||||||
post.reload
|
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
|
||||||
render json: PostRepr.base(post), status: :created
|
|
||||||
else
|
|
||||||
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
|
||||||
end
|
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
|
rescue Tag::NicoTagNormalisationError
|
||||||
head :bad_request
|
head :bad_request
|
||||||
end
|
end
|
||||||
@@ -166,19 +169,22 @@ class PostsController < ApplicationController
|
|||||||
original_created_before = params[:original_created_before]
|
original_created_before = params[:original_created_before]
|
||||||
|
|
||||||
post = Post.find(params[:id].to_i)
|
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 +
|
tags = post.tags.where(category: 'nico').to_a +
|
||||||
Tag.normalise_tags(tag_names, with_tagme: false)
|
Tag.normalise_tags(tag_names, with_tagme: false)
|
||||||
tags = Tag.expand_parent_tags(tags)
|
tags = Tag.expand_parent_tags(tags)
|
||||||
sync_post_tags!(post, tags)
|
sync_post_tags!(post, tags)
|
||||||
|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
|
||||||
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
|
|
||||||
end
|
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
|
rescue Tag::NicoTagNormalisationError
|
||||||
head :bad_request
|
head :bad_request
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
has_many :user_post_views, dependent: :delete_all
|
has_many :user_post_views, dependent: :delete_all
|
||||||
has_many :post_similarities, dependent: :delete_all
|
has_many :post_similarities, dependent: :delete_all
|
||||||
|
has_many :post_versions
|
||||||
|
|
||||||
has_one_attached :thumbnail
|
has_one_attached :thumbnail
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ class Post < ApplicationRecord
|
|||||||
super(options).merge(thumbnail: nil)
|
super(options).merge(thumbnail: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||||
|
|
||||||
def related limit: nil
|
def related limit: nil
|
||||||
ids = post_similarities.order(cos: :desc)
|
ids = post_similarities.order(cos: :desc)
|
||||||
ids = ids.limit(limit) if limit
|
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
|
class Tag < ApplicationRecord
|
||||||
include MyDiscard
|
include MyDiscard
|
||||||
|
|
||||||
@@ -150,6 +153,8 @@ class Tag < ApplicationRecord
|
|||||||
def self.merge_tags! target_tag, source_tags
|
def self.merge_tags! target_tag, source_tags
|
||||||
target_tag => Tag
|
target_tag => Tag
|
||||||
|
|
||||||
|
affected_post_ids = Set.new
|
||||||
|
|
||||||
Tag.transaction do
|
Tag.transaction do
|
||||||
Array(source_tags).compact.uniq.each do |source_tag|
|
Array(source_tags).compact.uniq.each do |source_tag|
|
||||||
source_tag => Tag
|
source_tag => Tag
|
||||||
@@ -158,6 +163,7 @@ class Tag < ApplicationRecord
|
|||||||
|
|
||||||
source_tag.post_tags.kept.find_each do |source_pt|
|
source_tag.post_tags.kept.find_each do |source_pt|
|
||||||
post_id = source_pt.post_id
|
post_id = source_pt.post_id
|
||||||
|
affected_post_ids << post_id
|
||||||
source_pt.discard_by!(nil)
|
source_pt.discard_by!(nil)
|
||||||
unless PostTag.kept.exists?(post_id:, tag: target_tag)
|
unless PostTag.kept.exists?(post_id:, tag: target_tag)
|
||||||
PostTag.create!(post_id:, tag: target_tag)
|
PostTag.create!(post_id:, tag: target_tag)
|
||||||
@@ -180,6 +186,10 @@ class Tag < ApplicationRecord
|
|||||||
end
|
end
|
||||||
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)
|
target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Generated
+25
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) 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|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -132,6 +132,27 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do
|
|||||||
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
|
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
|
||||||
end
|
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|
|
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "title"
|
t.string "title"
|
||||||
t.string "url", limit: 768, null: false
|
t.string "url", limit: 768, null: false
|
||||||
@@ -362,6 +383,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do
|
|||||||
add_foreign_key "post_tags", "tags"
|
add_foreign_key "post_tags", "tags"
|
||||||
add_foreign_key "post_tags", "users", column: "created_user_id"
|
add_foreign_key "post_tags", "users", column: "created_user_id"
|
||||||
add_foreign_key "post_tags", "users", column: "deleted_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", "posts", column: "parent_id"
|
||||||
add_foreign_key "posts", "users", column: "uploaded_user_id"
|
add_foreign_key "posts", "users", column: "uploaded_user_id"
|
||||||
add_foreign_key "settings", "users"
|
add_foreign_key "settings", "users"
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ namespace :nico do
|
|||||||
original_created_from = original_created_at&.change(sec: 0)
|
original_created_from = original_created_at&.change(sec: 0)
|
||||||
original_created_before = original_created_from&.+(1.minute)
|
original_created_before = original_created_from&.+(1.minute)
|
||||||
|
|
||||||
|
post_created = false
|
||||||
|
post_changed = false
|
||||||
|
|
||||||
if post
|
if post
|
||||||
attrs = { title:, original_created_from:, original_created_before: }
|
attrs = { title:, original_created_from:, original_created_before: }
|
||||||
|
|
||||||
@@ -76,11 +79,13 @@ namespace :nico do
|
|||||||
end
|
end
|
||||||
|
|
||||||
post.assign_attributes(attrs)
|
post.assign_attributes(attrs)
|
||||||
if post.changed?
|
post_changed = post.changed?
|
||||||
|
if post_changed
|
||||||
post.save!
|
post.save!
|
||||||
post.resized_thumbnail! if post.thumbnail.attached?
|
post.resized_thumbnail! if post.thumbnail.attached?
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
post_created = true
|
||||||
url = "https://www.nicovideo.jp/watch/#{ code }"
|
url = "https://www.nicovideo.jp/watch/#{ code }"
|
||||||
thumbnail_base = fetch_thumbnail.(url) rescue nil
|
thumbnail_base = fetch_thumbnail.(url) rescue nil
|
||||||
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
|
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
|
||||||
@@ -140,6 +145,12 @@ namespace :nico do
|
|||||||
desired_all_tag_ids.uniq!
|
desired_all_tag_ids.uniq!
|
||||||
|
|
||||||
sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)
|
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
|
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)
|
expect(target_tag.reload.post_count).to eq(0)
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -795,4 +795,127 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(user.reload.viewed?(post_record)).to be(false)
|
expect(user.reload.viewed?(post_record)).to be(false)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -90,4 +90,128 @@ RSpec.describe "nico:sync" do
|
|||||||
expect(active_names).to include("nico:NEW")
|
expect(active_names).to include("nico:NEW")
|
||||||
expect(active_names).not_to include("nico:OLD")
|
expect(active_names).not_to include("nico:OLD")
|
||||||
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
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|||||||
Generated
+925
-181
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||||
|
"@mdx-js/react": "^3.1.1",
|
||||||
|
"@mdx-js/rollup": "^3.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-toast": "^1.2.14",
|
"@radix-ui/react-toast": "^1.2.14",
|
||||||
@@ -37,13 +39,15 @@
|
|||||||
"react-youtube": "^10.1.0",
|
"react-youtube": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"zustand": "^5.0.8",
|
"unist-util-visit-parents": "^6.0.1",
|
||||||
"unist-util-visit-parents": "^6.0.1"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/axios": "^0.14.4",
|
"@types/axios": "^0.14.4",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.0.13",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
|
|||||||
+42
-35
@@ -1,4 +1,4 @@
|
|||||||
import { AnimatePresence, LayoutGroup } from 'framer-motion'
|
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { BrowserRouter,
|
import { BrowserRouter,
|
||||||
Navigate,
|
Navigate,
|
||||||
@@ -15,8 +15,10 @@ import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
|
|||||||
import MaterialListPage from '@/pages/materials/MaterialListPage'
|
import MaterialListPage from '@/pages/materials/MaterialListPage'
|
||||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||||
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
||||||
|
import MorePage from '@/pages/MorePage'
|
||||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
|
import TOSPage from '@/pages/TOSPage.mdx'
|
||||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||||
import PostListPage from '@/pages/posts/PostListPage'
|
import PostListPage from '@/pages/posts/PostListPage'
|
||||||
@@ -44,36 +46,36 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutGroup id="gallery-shared">
|
<AnimatePresence mode="wait">
|
||||||
<AnimatePresence mode="wait">
|
<Routes location={location}>
|
||||||
<Routes location={location}>
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
<Route path="/tags" element={<TagListPage/>}/>
|
||||||
<Route path="/tags" element={<TagListPage/>}/>
|
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
|
||||||
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
|
<Route path="/materials" element={<MaterialBasePage/>}>
|
||||||
<Route path="/materials" element={<MaterialBasePage/>}>
|
<Route index element={<MaterialListPage/>}/>
|
||||||
<Route index element={<MaterialListPage/>}/>
|
<Route path="new" element={<MaterialNewPage/>}/>
|
||||||
<Route path="new" element={<MaterialNewPage/>}/>
|
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
||||||
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
</Route>
|
||||||
</Route>
|
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
|
||||||
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
|
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
||||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
||||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
||||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
<Route path="/tos" element={<TOSPage/>}/>
|
||||||
<Route path="*" element={<NotFound/>}/>
|
<Route path="/more" element={<MorePage/>}/>
|
||||||
</Routes>
|
<Route path="*" element={<NotFound/>}/>
|
||||||
</AnimatePresence>
|
</Routes>
|
||||||
</LayoutGroup>)
|
</AnimatePresence>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -131,10 +133,15 @@ export default (() => {
|
|||||||
<>
|
<>
|
||||||
<RouteBlockerOverlay/>
|
<RouteBlockerOverlay/>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="flex flex-col h-dvh w-screen">
|
<LayoutGroup>
|
||||||
<TopNav user={user}/>
|
<motion.div
|
||||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
layout="position"
|
||||||
</div>
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
|
className="flex flex-col h-dvh w-full overflow-y-hidden">
|
||||||
|
<TopNav user={user}/>
|
||||||
|
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||||
|
</motion.div>
|
||||||
|
</LayoutGroup>
|
||||||
<Toaster/>
|
<Toaster/>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</>)
|
</>)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default (({ posts, onClick }: Props) => {
|
|||||||
cardRef.current.style.zIndex = ''
|
cardRef.current.style.zIndex = ''
|
||||||
cardRef.current.style.position = ''
|
cardRef.current.style.position = ''
|
||||||
}}
|
}}
|
||||||
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
|
||||||
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
||||||
alt={post.title || post.url}
|
alt={post.title || post.url}
|
||||||
title={post.title || post.url || undefined}
|
title={post.title || post.url || undefined}
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ export default (({ posts, onClick }: Props) => {
|
|||||||
{CATEGORIES.flatMap (cat => cat in tags ? (
|
{CATEGORIES.flatMap (cat => cat in tags ? (
|
||||||
tags[cat].map (tag => (
|
tags[cat].map (tag => (
|
||||||
<li key={tag.id} className="mb-1">
|
<li key={tag.id} className="mb-1">
|
||||||
<motion.div layoutId={`tag-${ tag.id }`}>
|
<motion.div
|
||||||
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
|
layoutId={`tag-${ tag.id }`}>
|
||||||
<TagLink tag={tag} onClick={onClick}/>
|
<TagLink tag={tag} onClick={onClick}/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</li>))) : [])}
|
</li>))) : [])}
|
||||||
|
|||||||
+233
-108
@@ -14,11 +14,65 @@ import { fetchWikiPage } from '@/lib/wiki'
|
|||||||
|
|
||||||
import type { FC, MouseEvent } from 'react'
|
import type { FC, MouseEvent } from 'react'
|
||||||
|
|
||||||
import type { Menu, User } from '@/types'
|
import type { Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
|
|
||||||
|
export const menuOutline = ({ tag, wikiId, user, pathName }: {
|
||||||
|
tag?: Tag | null
|
||||||
|
wikiId: number | null
|
||||||
|
user: User | null,
|
||||||
|
pathName: string }): Menu => {
|
||||||
|
const postCount = tag?.postCount ?? 0
|
||||||
|
|
||||||
|
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId)
|
||||||
|
const wikiTitle = pathName.split ('/')[2] ?? ''
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: '広場', to: '/posts', subMenu: [
|
||||||
|
{ name: '一覧', to: '/posts' },
|
||||||
|
{ name: '検索', to: '/posts/search' },
|
||||||
|
{ name: '追加', to: '/posts/new' },
|
||||||
|
{ name: '履歴', to: '/posts/changes' },
|
||||||
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||||
|
{ name: 'タグ', to: '/tags', subMenu: [
|
||||||
|
{ 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', visible: false, 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' },
|
||||||
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
|
||||||
|
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||||||
|
{ name: '検索', to: '/wiki' },
|
||||||
|
{ name: '新規', to: '/wiki/new' },
|
||||||
|
{ name: '全体履歴', to: '/wiki/changes' },
|
||||||
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
|
||||||
|
{ component: <Separator/>, visible: wikiPageFlg },
|
||||||
|
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
|
||||||
|
visible: wikiPageFlg },
|
||||||
|
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||||
|
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
||||||
|
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
||||||
|
{ name: '一覧', to: '/users', visible: false },
|
||||||
|
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||||
|
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] },
|
||||||
|
{ name: '法規', visible: false, subMenu: [
|
||||||
|
{ name: '利用規約', to: '/tos' }] }]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default (({ user }: Props) => {
|
export default (({ user }: Props) => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
|
||||||
@@ -26,25 +80,30 @@ export default (({ user }: Props) => {
|
|||||||
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
||||||
const navRef = useRef<HTMLDivElement | null> (null)
|
const navRef = useRef<HTMLDivElement | null> (null)
|
||||||
|
|
||||||
const measure = () => {
|
const measure = (idx: number) => {
|
||||||
const nav = navRef.current
|
const nav = navRef.current
|
||||||
const el = itemsRef.current[activeIdx]
|
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
|
||||||
if (!(nav) || !(el) || activeIdx < 0)
|
|
||||||
return
|
if (!(nav) || !(el))
|
||||||
|
{
|
||||||
|
setHL ({ left: 0, width: 0, visible: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const navRect = nav.getBoundingClientRect ()
|
const navRect = nav.getBoundingClientRect ()
|
||||||
const elRect = el.getBoundingClientRect ()
|
const elRect = el.getBoundingClientRect ()
|
||||||
|
|
||||||
setHl ({ left: elRect.left - navRect.left,
|
setHL ({ left: elRect.left - navRect.left,
|
||||||
width: elRect.width,
|
width: elRect.width,
|
||||||
visible: true })
|
visible: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
|
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
|
||||||
left: 0,
|
left: 0,
|
||||||
width: 0,
|
width: 0,
|
||||||
visible: false })
|
visible: false })
|
||||||
const [menuOpen, setMenuOpen] = useState (false)
|
const [menuOpen, setMenuOpen] = useState (false)
|
||||||
|
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||||||
|
|
||||||
@@ -62,51 +121,10 @@ export default (({ user }: Props) => {
|
|||||||
queryKey: tagsKeys.show (effectiveTitle),
|
queryKey: tagsKeys.show (effectiveTitle),
|
||||||
queryFn: () => fetchTagByName (effectiveTitle) })
|
queryFn: () => fetchTagByName (effectiveTitle) })
|
||||||
|
|
||||||
const postCount = tag?.postCount ?? 0
|
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
|
||||||
|
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
|
||||||
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId)
|
const activeIdx =
|
||||||
const wikiTitle = location.pathname.split ('/')[2] ?? ''
|
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
||||||
const menu: Menu = [
|
|
||||||
{ name: '広場', to: '/posts', subMenu: [
|
|
||||||
{ name: '一覧', to: '/posts' },
|
|
||||||
{ name: '検索', to: '/posts/search' },
|
|
||||||
{ name: '追加', to: '/posts/new' },
|
|
||||||
{ name: '履歴', to: '/posts/changes' },
|
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
|
||||||
{ name: 'タグ', to: '/tags', subMenu: [
|
|
||||||
{ name: 'マスタ', to: '/tags' },
|
|
||||||
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
|
||||||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
|
||||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
|
||||||
{ name: '素材', to: '/materials', subMenu: [
|
|
||||||
{ name: '一覧', to: '/materials' },
|
|
||||||
// { name: '検索', to: '/materials/search' },
|
|
||||||
{ name: '追加', to: '/materials/new' },
|
|
||||||
// { name: '履歴', to: '/materials/changes' },
|
|
||||||
{ name: 'ヘルプ', to: 'wiki/ヘルプ:素材集' }] },
|
|
||||||
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
|
|
||||||
{ name: <>第 1 会場</>, to: '/theatres/1' },
|
|
||||||
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
|
|
||||||
{ name: <>ニジカ放送局第 1 チャンネル</>,
|
|
||||||
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
|
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
|
|
||||||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
|
||||||
{ name: '検索', to: '/wiki' },
|
|
||||||
{ name: '新規', to: '/wiki/new' },
|
|
||||||
{ name: '全体履歴', to: '/wiki/changes' },
|
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
|
|
||||||
{ component: <Separator/>, visible: wikiPageFlg },
|
|
||||||
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
|
|
||||||
visible: wikiPageFlg },
|
|
||||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
|
||||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
|
||||||
{ name: 'ユーザ', to: '/users/settings', subMenu: [
|
|
||||||
{ name: '一覧', to: '/users', visible: false },
|
|
||||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
|
||||||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
|
|
||||||
|
|
||||||
const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
|
||||||
|
|
||||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||||
|
|
||||||
@@ -119,35 +137,31 @@ export default (({ user }: Props) => {
|
|||||||
const dir = dirRef.current
|
const dir = dirRef.current
|
||||||
|
|
||||||
useLayoutEffect (() => {
|
useLayoutEffect (() => {
|
||||||
if (activeIdx < 0)
|
const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
|
||||||
return
|
const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
|
||||||
|
|
||||||
const raf = requestAnimationFrame (measure)
|
|
||||||
const onResize = () => requestAnimationFrame (measure)
|
|
||||||
|
|
||||||
addEventListener ('resize', onResize)
|
addEventListener ('resize', onResize)
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame (raf)
|
cancelAnimationFrame (raf)
|
||||||
removeEventListener ('resize', onResize)
|
removeEventListener ('resize', onResize)
|
||||||
}
|
}
|
||||||
}, [activeIdx])
|
})
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||||
return () => unsubscribe ()
|
return () => unsubscribe ()
|
||||||
}, [])
|
}, [activeIdx])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setMenuOpen (false)
|
setMenuOpen (false)
|
||||||
setOpenItemIdx (menu.findIndex (item => (
|
setOpenItemIdx (activeIdx)
|
||||||
location.pathname.startsWith (item.base || item.to))))
|
|
||||||
}, [location])
|
}, [location])
|
||||||
|
|
||||||
return (
|
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">
|
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
|
<PrefetchLink
|
||||||
to="/posts"
|
to="/posts"
|
||||||
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
|
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
|
||||||
@@ -158,26 +172,48 @@ export default (({ user }: Props) => {
|
|||||||
ぼざクリ タグ広場
|
ぼざクリ タグ広場
|
||||||
</PrefetchLink>
|
</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
|
<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',
|
'bg-yellow-200 dark:bg-red-950',
|
||||||
'transition-[transform,width] duration-200 ease-out')}
|
'transition-[transform,width] duration-200 ease-out')}
|
||||||
style={{ width: hl.width,
|
style={{ width: hl.width,
|
||||||
transform: `translate(${ hl.left }px, -50%)`,
|
transform: `translateX(${ hl.left }px)`,
|
||||||
opacity: hl.visible ? 1 : 0 }}/>
|
opacity: hl.visible ? 1 : 0 }}/>
|
||||||
|
|
||||||
{menu.map ((item, i) => (
|
{visibleMenu.map ((item, i) => (
|
||||||
<PrefetchLink
|
<motion.div
|
||||||
key={i}
|
key={item.to}
|
||||||
to={item.to}
|
layoutId={`menu-${ item.name }`}
|
||||||
ref={(el: (HTMLAnchorElement | null)) => {
|
animate={{ opacity: moreVsbl ? 0 : 1 }}
|
||||||
itemsRef.current[i] = el
|
transition={{ opacity: { duration: .12 },
|
||||||
}}
|
layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
className={cn ('relative z-10 flex h-full items-center px-5',
|
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
|
||||||
(i === openItemIdx) && 'font-bold')}>
|
onMouseEnter={() => setMoreVsbl (false)}>
|
||||||
{item.name}
|
<PrefetchLink
|
||||||
</PrefetchLink>))}
|
to={item.to}
|
||||||
|
ref={(el: (HTMLAnchorElement | null)) => {
|
||||||
|
itemsRef.current[i] = el
|
||||||
|
}}
|
||||||
|
className={cn ('relative z-10 flex h-full items-center px-5',
|
||||||
|
(i === openItemIdx) && 'font-bold')}>
|
||||||
|
{item.name}
|
||||||
|
</PrefetchLink>
|
||||||
|
</motion.div>))}
|
||||||
|
<PrefetchLink
|
||||||
|
to="/more"
|
||||||
|
ref={(el: (HTMLAnchorElement | null)) => {
|
||||||
|
itemsRef.current[visibleMenu.length] = el
|
||||||
|
}}
|
||||||
|
onClick={() => setMoreVsbl (false)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setMoreVsbl (true)
|
||||||
|
measure (-1)
|
||||||
|
}}
|
||||||
|
className={cn ('relative z-10 flex h-full items-center px-5',
|
||||||
|
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
||||||
|
その他 »
|
||||||
|
</PrefetchLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,36 +231,115 @@ export default (({ user }: Props) => {
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
|
<AnimatePresence initial={false}>
|
||||||
items-center w-full min-h-[40px] overflow-hidden">
|
<motion.div
|
||||||
<AnimatePresence initial={false} custom={dir}>
|
key="submenu-shell"
|
||||||
<motion.div
|
layout
|
||||||
key={activeIdx}
|
className="relative hidden md:block overflow-hidden
|
||||||
custom={dir}
|
bg-yellow-200 dark:bg-red-950"
|
||||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }}
|
||||||
centre: { y: 0, opacity: 1 },
|
onMouseLeave={() => {
|
||||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
if (moreVsbl)
|
||||||
className="absolute inset-0 flex items-center px-3"
|
setMoreVsbl (false)
|
||||||
initial="enter"
|
}}
|
||||||
animate="centre"
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
exit="exit"
|
onAnimationComplete={() => {
|
||||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
measure (moreVsbl ? -1 : activeIdx)
|
||||||
{(menu[activeIdx]?.subMenu ?? [])
|
}}>
|
||||||
.filter (item => item.visible ?? true)
|
{moreVsbl
|
||||||
.map ((item, i) => (
|
? (
|
||||||
'component' in item
|
menu.map ((item, i) => (
|
||||||
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
|
<div key={i} className="relative h-[40px]">
|
||||||
: (
|
<div className="absolute inset-0 flex items-center px-3">
|
||||||
<PrefetchLink
|
<motion.div
|
||||||
key={`l-${ i }`}
|
transition={{ duration: .2, ease: 'easeOut' }}
|
||||||
to={item.to}
|
{...((item.visible ?? true)
|
||||||
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
? { layoutId: `menu-${ item.name }` }
|
||||||
className="h-full flex items-center px-3">
|
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||||
{item.name}
|
animate: { x: 0, y: 0, opacity: 1 },
|
||||||
</PrefetchLink>)))}
|
exit: { x: 40, y: -40, opacity: 0 } })}
|
||||||
</motion.div>
|
className="z-10 h-full flex items-center px-3 font-bold w-24">
|
||||||
</AnimatePresence>
|
<h2>{item.name}</h2>
|
||||||
</div>
|
</motion.div>
|
||||||
|
{item.subMenu
|
||||||
|
.filter (subItem => subItem.visible ?? true)
|
||||||
|
.map ((subItem, j) => (
|
||||||
|
'component' in subItem
|
||||||
|
? (
|
||||||
|
<motion.div
|
||||||
|
key={`c-${ i }-${ j }`}
|
||||||
|
transition={{ duration: .2, ease: 'easeOut' }}
|
||||||
|
{...((visibleMenu[activeIdx]?.name
|
||||||
|
=== item.name)
|
||||||
|
? { layoutId: `submenu-${ item.name }-${ j }` }
|
||||||
|
: { initial: { y: -40, opacity: 0 },
|
||||||
|
animate: { y: 0, opacity: 1 },
|
||||||
|
exit: { y: -40, opacity: 0 } })}>
|
||||||
|
{subItem.component}
|
||||||
|
</motion.div>)
|
||||||
|
: (
|
||||||
|
<motion.div
|
||||||
|
key={`l-${ i }-${ j }`}
|
||||||
|
transition={{ duration: .2, ease: 'easeOut' }}
|
||||||
|
{...((visibleMenu[activeIdx]?.name
|
||||||
|
=== item.name)
|
||||||
|
? { layoutId: `submenu-${ item.name }-${ j }` }
|
||||||
|
: { initial: { y: -40, opacity: 0 },
|
||||||
|
animate: { y: 0, opacity: 1 },
|
||||||
|
exit: { y: -40, opacity: 0 } })}>
|
||||||
|
<PrefetchLink
|
||||||
|
to={subItem.to}
|
||||||
|
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||||
|
onClick={() => setMoreVsbl (false)}
|
||||||
|
className="h-full flex items-center px-3">
|
||||||
|
{subItem.name}
|
||||||
|
</PrefetchLink>
|
||||||
|
</motion.div>)))}
|
||||||
|
</div>
|
||||||
|
</div>)))
|
||||||
|
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
|
||||||
|
&& (
|
||||||
|
<div className="relative h-[40px]">
|
||||||
|
<AnimatePresence initial={false} custom={dir}>
|
||||||
|
<motion.div
|
||||||
|
key={activeIdx}
|
||||||
|
custom={dir}
|
||||||
|
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||||
|
centre: { y: 0, opacity: 1 },
|
||||||
|
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||||
|
className="absolute inset-0 flex items-center px-3"
|
||||||
|
initial="enter"
|
||||||
|
animate="centre"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||||
|
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||||
|
.filter (item => item.visible ?? true)
|
||||||
|
.map ((item, i) => (
|
||||||
|
'component' in item
|
||||||
|
? (
|
||||||
|
<motion.div
|
||||||
|
key={`c-${ i }`}
|
||||||
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
|
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||||||
|
{item.component}
|
||||||
|
</motion.div>)
|
||||||
|
: (
|
||||||
|
<motion.div
|
||||||
|
key={`l-${ i }`}
|
||||||
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
|
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||||||
|
<PrefetchLink
|
||||||
|
to={item.to}
|
||||||
|
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||||
|
className="h-full flex items-center px-3">
|
||||||
|
{item.name}
|
||||||
|
</PrefetchLink>
|
||||||
|
</motion.div>)))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
@@ -241,7 +356,7 @@ export default (({ user }: Props) => {
|
|||||||
exit="closed"
|
exit="closed"
|
||||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
{menu.map ((item, i) => (
|
{visibleMenu.map ((item, i) => (
|
||||||
<Fragment key={i}>
|
<Fragment key={i}>
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={i === openItemIdx ? item.to : '#'}
|
to={i === openItemIdx ? item.to : '#'}
|
||||||
@@ -294,6 +409,16 @@ export default (({ user }: Props) => {
|
|||||||
</motion.div>)}
|
</motion.div>)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</Fragment>))}
|
</Fragment>))}
|
||||||
|
<PrefetchLink
|
||||||
|
to="/more"
|
||||||
|
ref={(el: (HTMLAnchorElement | null)) => {
|
||||||
|
itemsRef.current[visibleMenu.length] = el
|
||||||
|
}}
|
||||||
|
className={cn ('w-full min-h-[40px] flex items-center pl-8',
|
||||||
|
((openItemIdx < 0)
|
||||||
|
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
|
||||||
|
その他 »
|
||||||
|
</PrefetchLink>
|
||||||
<TopNavUser user={user} sp/>
|
<TopNavUser user={user} sp/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
</motion.div>)}
|
</motion.div>)}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
import remarkGFM from 'remark-gfm'
|
import remarkGFM from 'remark-gfm'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import SectionTitle from '@/components/common/SectionTitle'
|
|
||||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
|
||||||
import { wikiKeys } from '@/lib/queryKeys'
|
import { wikiKeys } from '@/lib/queryKeys'
|
||||||
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
|
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
|
||||||
import { fetchWikiPages } from '@/lib/wiki'
|
import { fetchWikiPages } from '@/lib/wiki'
|
||||||
@@ -16,19 +14,15 @@ import type { Components } from 'react-markdown'
|
|||||||
type Props = { title: string
|
type Props = { title: string
|
||||||
body?: string }
|
body?: string }
|
||||||
|
|
||||||
const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>,
|
const mdComponents = { a: (({ href, children }) => (
|
||||||
h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>,
|
['/', '.'].some (e => href?.startsWith (e))
|
||||||
ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>,
|
? <PrefetchLink to={href!}>{children}</PrefetchLink>
|
||||||
ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>,
|
: (
|
||||||
a: (({ href, children }) => (
|
<a href={href}
|
||||||
['/', '.'].some (e => href?.startsWith (e))
|
target="_blank"
|
||||||
? <PrefetchLink to={href!}>{children}</PrefetchLink>
|
rel="noopener noreferrer">
|
||||||
: (
|
{children}
|
||||||
<a href={href}
|
</a>))) } as const satisfies Components
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
{children}
|
|
||||||
</a>))) } as const satisfies Components
|
|
||||||
|
|
||||||
|
|
||||||
export default (({ title, body }: Props) => {
|
export default (({ title, body }: Props) => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React from 'react'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type Props = { children: React.ReactNode }
|
import type { ComponentPropsWithoutRef, FC } from 'react'
|
||||||
|
|
||||||
|
type Props = ComponentPropsWithoutRef<'h2'>
|
||||||
|
|
||||||
|
|
||||||
export default ({ children }: Props) => (
|
export default (({ children, className, ...rest }: Props) => (
|
||||||
<h2 className="text-xl my-4">
|
<h2 {...rest} className={cn ('text-xl my-4', className)}>
|
||||||
{children}
|
{children}
|
||||||
</h2>)
|
</h2>)) satisfies FC<Props>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
@@ -8,8 +10,9 @@ type Props = {
|
|||||||
|
|
||||||
|
|
||||||
export default (({ children, className }: Props) => (
|
export default (({ children, className }: Props) => (
|
||||||
<main className={cn ('flex-1 overflow-y-auto p-4',
|
<motion.main
|
||||||
'md:h-[calc(100dvh-88px)] md:overflow-y-auto',
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
className)}>
|
className={cn ('flex-1 overflow-y-auto p-4', className)}
|
||||||
|
layout="position">
|
||||||
{children}
|
{children}
|
||||||
</main>)) satisfies FC<Props>
|
</motion.main>)) satisfies FC<Props>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
@@ -6,10 +7,10 @@ type Props = { children: ReactNode }
|
|||||||
|
|
||||||
|
|
||||||
export default (({ children }: Props) => (
|
export default (({ children }: Props) => (
|
||||||
<div
|
<motion.div
|
||||||
className="p-4 w-full md:w-64 md:h-full
|
layout="position"
|
||||||
md:h-[calc(100dvh-88px)] md:overflow-y-auto
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
sidebar">
|
className="p-4 w-full md:w-64 md:h-full md:overflow-y-auto sidebar">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
@@ -26,4 +27,4 @@ export default (({ children }: Props) => (
|
|||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>)) satisfies FC<Props>
|
</motion.div>)) satisfies FC<Props>
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ a
|
|||||||
body
|
body
|
||||||
{
|
{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
}
|
||||||
|
|
||||||
|
#root
|
||||||
|
{
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1
|
h1
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { MDXComponents } from 'mdx/types'
|
||||||
|
|
||||||
|
|
||||||
|
export const useMDXComponents = (): MDXComponents => ({ })
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import { menuOutline } from '@/components/TopNav'
|
||||||
|
import SectionTitle from '@/components/common/SectionTitle'
|
||||||
|
import MainArea from '@/components/layout/MainArea'
|
||||||
|
import { SITE_TITLE } from '@/config'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
|
export default (() => {
|
||||||
|
const menu = menuOutline (
|
||||||
|
{ tag: null, wikiId: null, user: { } as User, pathName: location.pathname })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainArea className="md:flex">
|
||||||
|
<Helmet>
|
||||||
|
<title>{`メニュー | ${ SITE_TITLE }`}</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
{[...Array (Math.ceil (menu.length / 4)).keys ()].map (i => (
|
||||||
|
<div key={i} className="flex-1 mx-16">
|
||||||
|
{menu.slice (4 * i, 4 * (i + 1)).map ((item, j) => (
|
||||||
|
<section key={j}>
|
||||||
|
<SectionTitle className="font-bold">{item.name}</SectionTitle>
|
||||||
|
<ul>
|
||||||
|
{item.subMenu
|
||||||
|
.filter (subItem => (subItem.visible ?? true))
|
||||||
|
.map ((subItem, k) => ('name' in subItem && (
|
||||||
|
<li key={k}>
|
||||||
|
<PrefetchLink
|
||||||
|
to={subItem.to}
|
||||||
|
target={subItem.to.slice (0, 2) === '//'
|
||||||
|
? '_blank'
|
||||||
|
: undefined}>
|
||||||
|
{subItem.name}
|
||||||
|
</PrefetchLink>
|
||||||
|
</li>)))}
|
||||||
|
</ul>
|
||||||
|
</section>))}
|
||||||
|
</div>))}
|
||||||
|
</MainArea>)
|
||||||
|
}) satisfies FC
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
import MainArea from '@/components/layout/MainArea'
|
||||||
|
import { SITE_TITLE } from '@/config'
|
||||||
|
import { dateString } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const lastUpdatedAt = dateString ('2026-04-12', 'hour')
|
||||||
|
|
||||||
|
|
||||||
|
<MainArea>
|
||||||
|
<Helmet>
|
||||||
|
<title>{`利用規約 | ${ SITE_TITLE }`}</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<article className="prose dark:prose-invert mx-auto p-4">
|
||||||
|
# 利用規約
|
||||||
|
|
||||||
|
最終更新日: {lastUpdatedAt}
|
||||||
|
|
||||||
|
この利用規約(以下「本規約」)は、ぼざクリ タグ広場(以下「本サービス」)の利用条件を定めるものです。利用者は、本サービスを利用した時点で、本規約に同意したものとみなされます。
|
||||||
|
|
||||||
|
## 第 1 条 本サービスの位置づけ
|
||||||
|
|
||||||
|
1. 本サービスは、タグ・Wiki・外部リンクの整理を中心とする知識共有基盤です。
|
||||||
|
2. 本サービスの中心価値は、コンテンツそのものの再配布ではなく、タグを軸にした整理、検索、再発見、および周辺知識の蓄積にあります。
|
||||||
|
3. 本サービスは、運営上の必要に応じて、機能、公開範囲、名称、URL、表示内容その他の仕様を変更することがあります。
|
||||||
|
|
||||||
|
## 第 2 条 公開方針と利用者区分
|
||||||
|
|
||||||
|
1. 本サービスは、初回一般公開時点では、**誰でも閲覧できる一方で、投稿・編輯は申請制** とします。
|
||||||
|
2. 初回一般公開時点では、通常の農奴は閲覧のみを行えます。
|
||||||
|
3. 投稿、タグ編輯、Wiki 編輯その他の耕作行為は、運営が承認した利用者(以下「耕作員」)に限って認めます。
|
||||||
|
4. 独裁者は、耕作員に加えて、差戻、削除、利用制限、その他の管理操作を行えます。
|
||||||
|
5. 運営は、履歴管理、差戻、BAN 運用、監査導線その他の運営装備がじゅうぶんに整ったと判断した場合、農奴に一部の編輯権限を開放することがあります。
|
||||||
|
6. 利用者区分、権限範囲、申請条件、承認基準、承認後の取扱いは、運営が必要に応じて定め、変更できます。
|
||||||
|
|
||||||
|
## 第 3 条 利用開始と引継ぎコード
|
||||||
|
|
||||||
|
1. 本サービスでは、一般的な Id. / パスワード方式ではなく、運営が別途定める認証情報または引継ぎコードを用いる場合があります。
|
||||||
|
2. 利用者は、自身に割り当てられた引継ぎコード、認証情報、端末上の保存情報を自己の責任で管理するものとします。
|
||||||
|
3. 利用者は、自己の引継ぎコードまたは認証情報を第 3 者に譲渡、貸与、共有、漏洩してはなりません。
|
||||||
|
4. 引継ぎコードの漏洩、第 3 者利用、紛失、盗用その他の事故によって利用者または第 3 者に生じた損害について、運営は責任を負いません。
|
||||||
|
5. 運営は、本人確認、濫用対策、監査対応または保守のため、利用情報とアクセス元情報を関聯づけて扱うことがあります。
|
||||||
|
|
||||||
|
## 第 4 条 申請制編輯の基本ルール
|
||||||
|
|
||||||
|
1. 耕作員は、タグ整理基盤の品質維持を最優先し、個人的な所有主張ではなく、検索性、再利用性、可読性、整合性を重視して編輯しなければなりません。
|
||||||
|
2. 耕作員は、主観的な好悪、内輪ネタ、報復、私怨、対立誘導のためにタグや Wiki を操作してはなりません。
|
||||||
|
3. 耕作員は、誤りの修正、体系の整理、リンクの保守、知識の補足を目的として編輯を行うものとします。
|
||||||
|
4. 運営は、申請内容、過去の行動、編輯品質、聯絡可能性、運営負荷その他の事情を考慮して、承認、保留、拒否、取消を行えます。
|
||||||
|
5. 耕作員資格は権利ではなく、運営が本サービスの維持のために付与する可撤回の権限です。
|
||||||
|
|
||||||
|
## 第 5 条 禁止事項
|
||||||
|
|
||||||
|
利用者は、以下の行為をしてはなりません。
|
||||||
|
|
||||||
|
1. 法令または公序良俗に違反する行為。
|
||||||
|
2. 犯罪を助長し、またはこれに結びつく行為。
|
||||||
|
3. 著作権、著作者人格権、商標権、肖像権、パブリシティ権、プライバシー権その他第 3 者の権利を侵害する行為。
|
||||||
|
4. 無断転載、違法アップロード、違法複製物、海賊版、権限のない転載先への誘導、またはそれらを正当化、拡散、補助する行為。
|
||||||
|
5. 実在人物に関する名誉毀損、侮辱、差別、脅迫、晒し、つきまとい、嫌がらせ、私刑の扇動その他の加害行為。
|
||||||
|
6. 個人情報、非公開情報、秘匿されるべき情報を本人の承諾なく掲載、送信、共有、推測可能な形で開示する行為。
|
||||||
|
7. 虚偽の情報、誤解を招く情報、出典を偽装した情報、意図的なミスリード、荒らし目的のタグづけ、関係のないタグの大量付与、分類妨碍、検索妨碍その他の品質破壊行為。
|
||||||
|
8. マルウェア、フィッシング、詐欺、誘導広告、悪質なリダイレクト、危険な外部リンクその他利用者または運営に危害を与える行為。
|
||||||
|
9. 本サービスの趣旨に照らして不相当な政治的扇動、宗教勧誘、商業宣伝、連鎖的勧誘、スパム、同一内容の反復送信。
|
||||||
|
10. 未成年の安全に反する行為、児童性的搾取、違法または著しく不適切な性的表現、過度に露骨な性表現や残虐表現を、一般公開導線に無警告で流し込む行為。
|
||||||
|
11. 運営、他の利用者、外部サービスまたは第 3 者に著しい負担、不利益、混乱を生じさせる行為。
|
||||||
|
12. 前各号のいずれかを試みる行為、教唆する行為、容易にする行為。
|
||||||
|
13. その他、運営が本サービスの目的または安全な運営に照らして不適切と判断する行為。
|
||||||
|
|
||||||
|
## 第 6 条 投稿、タグ、Wiki 等の取扱い
|
||||||
|
|
||||||
|
1. 利用者は、自らが投稿、編輯、登録、送信または変更する情報について、必要な権利を有し、または適法に利用できる状態でなければなりません。
|
||||||
|
2. 利用者は、自らが行った投稿、タグづけ、Wiki 編輯、説明文、コメント、関聯づけその他の行為について責任を負います。
|
||||||
|
3. 利用者は、運営に対し、本サービスの運営、表示、複製、保存、配信、整形、引用、履歴表示、差戻、バックアップ、障碍対応および弘報のために必要な範囲で、当該利用者生成情報を無償で利用する非独占的な権利を許諾するものとします。
|
||||||
|
4. 前項の許諾は、本サービスの運営上必要な範囲に限られ、利用者の権利帰属自体を運営へ移転するものではありません。
|
||||||
|
5. 運営は、分類整合性、表記統一、誤記修正、別名統合、差戻その他の理由により、投稿、タグ、Wiki その他の内容を編輯、非表示化、削除、統合、分割または凍結できます。
|
||||||
|
|
||||||
|
## 第 7 条 外部リンクと埋め込み
|
||||||
|
|
||||||
|
1. 本サービスは、外部サイトへのリンク、外部コンテンツの埋め込みまたはそれらに関するメタデータを表示する場合があります。
|
||||||
|
2. 外部リンク先または埋め込み先の権利、利用条件、公開範囲、削除方針、広告、追跡、Cookie その他の取扱いは、当該外部サービスの定めに従います。
|
||||||
|
3. 運営は、外部リンク先の適法性、安全性、継続性、正確性、品質、可用性、または内容の完全性を保証しません。
|
||||||
|
4. 外部権利者からの申立て、運営判断、法令対応または安全性確保のため、運営は外部リンク、埋め込み、サムネイル、説明文その他の表示を制限、差替え、非表示または削除できます。
|
||||||
|
|
||||||
|
## 第 8 条 履歴、差戻、削除
|
||||||
|
|
||||||
|
1. 本サービスでは、保守、監査、荒らし対策、説明責任その他の目的で、投稿、タグ、Wiki その他の変更履歴を保持し、表示し、または内部的に参照することがあります。
|
||||||
|
2. 利用者は、一度行った編輯が、後に差戻、修正、非表示化または削除されることがあることをあらかじめ承諾するものとします。
|
||||||
|
3. 利用者が削除を希望した場合でも、法令上、保守上、監査上、紛争対応上またはバックアップ上の必要により、直ちに完全消去できないことがあります。
|
||||||
|
4. 運営は、本サービス全体の健全性を維持するため、説明の有無を問わず、履歴の表示範囲、保存期間、差戻方針、削除方針を定め、変更できます。
|
||||||
|
|
||||||
|
## 第 9 条 利用制限、資格取消、BAN
|
||||||
|
|
||||||
|
1. 運営は、利用者が次のいずれかに該当すると判断した場合、事前の通知なく、または通知後に、投稿・編輯の制限、耕作員資格の取消、コンテンツの非表示または削除、引継ぎコードの失効、ユーザ BAN、IP BAN その他必要な措置を行えます。
|
||||||
|
- 本規約に違反した場合
|
||||||
|
- 本サービスの趣旨に反する運用妨碍、荒らし、品質破壊行為を行った場合
|
||||||
|
- 運営からの確認、修正要請、停止要請に合理的理由なく応じない場合
|
||||||
|
- 登録情報、申請内容または説明に虚偽がある場合
|
||||||
|
- 安全性、法令順守、運営継続の観点から措置が必要と判断された場合
|
||||||
|
2. 運営は、前項の措置について、その理由、基準、証拠または内部判断過程を常に開示する義務を負いません。
|
||||||
|
3. 利用制限または資格取消後も、運営は、必要に応じて履歴、ログ、申請記録、通報記録その他のデータを保持できます。
|
||||||
|
|
||||||
|
## 第 10 条 未成年の利用
|
||||||
|
|
||||||
|
1. 運営は、未成年の安全確保の観点から、年齢に応じた表示制限、導線制御、非表示化、削除、申請拒否その他の措置を行えます。
|
||||||
|
2. 利用者は、未成年が閲覧しうる一般公開面において、未成年に不適切な内容を無警告で流し込まないものとします。
|
||||||
|
|
||||||
|
## 第 11 条 お問い合わせ、通報、御意見番
|
||||||
|
|
||||||
|
1. 利用者は、本サービスが別途案内する問い合わせ、通報または御意見板の導線を通じて、バグ報告、問題報告、削除要請その他の聯絡を行えます。
|
||||||
|
2. 運営は、すべての問い合わせに回答する義務を負わず、回答期限、対応結果または対応方法を保証しません。
|
||||||
|
|
||||||
|
## 第 12 条 免責
|
||||||
|
|
||||||
|
1. 運営は、本サービスについて、特定目的適合性、完全性、正確性、継続性、安全性、無瑕疵性、または利用者の期待への適合を保証しません。
|
||||||
|
2. 運営は、外部リンク先、外部埋め込み先、第 3 者投稿、利用者同士の紛争、通信障碍、データ消失、誤分類、誤リンク、誤記、差戻、機能停止または仕様変更によって生じた損害について、責任を負いません。
|
||||||
|
3. 本サービスは、予告なく停止、終了、変更または縮小されることがあります。
|
||||||
|
|
||||||
|
## 第 13 条 規約の変更
|
||||||
|
|
||||||
|
1. 運営は、法令改正、機能追加、運用方針の変更、安全対策、表現調整その他の理由により、本規約を変更できます。
|
||||||
|
2. 変更後の本規約は、本サービス上に掲載された時点または運営が別途定める時点から効力を生じます。
|
||||||
|
3. 変更後に利用を継続した利用者は、変更後の本規約に同意したものとみなされます。
|
||||||
|
|
||||||
|
## 第 14 条 準拠法および管轄
|
||||||
|
|
||||||
|
1. 本規約および本サービスの利用には、日本法を準拠法とします。
|
||||||
|
2. 本規約または本サービスに関して生じた一切の紛争については、運営の所在地を管轄する裁判所を第 1 審の専属的合意管轄裁判所とします。ただし、法令に別段の定めがある場合はこの限りではありません。
|
||||||
|
|
||||||
|
## 附則
|
||||||
|
|
||||||
|
本規約は、{lastUpdatedAt} から適用します。
|
||||||
|
</article>
|
||||||
|
</MainArea>
|
||||||
@@ -6,7 +6,7 @@ import type { FC } from 'react'
|
|||||||
|
|
||||||
|
|
||||||
export default (() => (
|
export default (() => (
|
||||||
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]">
|
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||||||
<MaterialSidebar/>
|
<MaterialSidebar/>
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
</div>)) satisfies FC
|
</div>)) satisfies FC
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default (({ user }: Props) => {
|
|||||||
: 'bg-gray-500 hover:bg-gray-600')
|
: 'bg-gray-500 hover:bg-gray-600')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]">
|
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
{(post?.thumbnail || post?.thumbnailBase) && (
|
{(post?.thumbnail || post?.thumbnailBase) && (
|
||||||
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
|
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export default (() => {
|
|||||||
}, [location.search])
|
}, [location.search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]" ref={containerRef}>
|
<div
|
||||||
|
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
|
||||||
|
ref={containerRef}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>
|
<title>
|
||||||
{tags.length
|
{tags.length
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export default (() => {
|
|||||||
return <ErrorScreen status={status}/>
|
return <ErrorScreen status={status}/>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:flex md:flex-1">
|
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
{theatre && (
|
{theatre && (
|
||||||
<title>
|
<title>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import PostList from '@/components/PostList'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
import WikiBody from '@/components/WikiBody'
|
import WikiBody from '@/components/WikiBody'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
|
||||||
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
@@ -107,15 +106,15 @@ export default () => {
|
|||||||
</PrefetchLink>) : '(最新)'}
|
</PrefetchLink>) : '(最新)'}
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
<PageTitle>
|
<article className="prose dark:prose-invert mx-auto p-4">
|
||||||
<TagLink tag={tag ?? defaultTag}
|
<h1 className="prose-a:no-underline">
|
||||||
withWiki={false}
|
<TagLink tag={tag ?? defaultTag}
|
||||||
withCount={false}
|
withWiki={false}
|
||||||
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
withCount={false}
|
||||||
</PageTitle>
|
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
||||||
<div className="prose mx-auto p-4">
|
</h1>
|
||||||
{loading ? 'Loading...' : <WikiBody title={title} body={wikiPage?.body}/>}
|
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
|
||||||
</div>
|
</article>
|
||||||
|
|
||||||
{(!(version) && posts.length > 0) && (
|
{(!(version) && posts.length > 0) && (
|
||||||
<TabGroup>
|
<TabGroup>
|
||||||
|
|||||||
+21
-7
@@ -63,10 +63,20 @@ export type Material = {
|
|||||||
|
|
||||||
export type Menu = MenuItem[]
|
export type Menu = MenuItem[]
|
||||||
|
|
||||||
export type MenuItem = {
|
export type MenuInvisibleItem = {
|
||||||
|
name: ReactNode
|
||||||
|
to?: string
|
||||||
|
base?: string
|
||||||
|
visible: false
|
||||||
|
subMenu: SubMenuItem[] }
|
||||||
|
|
||||||
|
export type MenuItem = MenuVisibleItem | MenuInvisibleItem
|
||||||
|
|
||||||
|
export type MenuVisibleItem = {
|
||||||
name: ReactNode
|
name: ReactNode
|
||||||
to: string
|
to: string
|
||||||
base?: string
|
base?: string
|
||||||
|
visible?: true
|
||||||
subMenu: SubMenuItem[] }
|
subMenu: SubMenuItem[] }
|
||||||
|
|
||||||
export type NicoTag = Tag & {
|
export type NicoTag = Tag & {
|
||||||
@@ -126,12 +136,16 @@ export type PostTagChange = {
|
|||||||
changeType: 'add' | 'remove'
|
changeType: 'add' | 'remove'
|
||||||
timestamp: string }
|
timestamp: string }
|
||||||
|
|
||||||
export type SubMenuItem =
|
export type SubMenuComponentItem = {
|
||||||
| { component: ReactNode
|
component: ReactNode
|
||||||
visible: boolean }
|
visible: boolean }
|
||||||
| { name: ReactNode
|
|
||||||
to: string
|
export type SubMenuItem = SubMenuComponentItem | SubMenuStringItem
|
||||||
visible?: boolean }
|
|
||||||
|
export type SubMenuStringItem = {
|
||||||
|
name: ReactNode
|
||||||
|
to: string
|
||||||
|
visible?: boolean }
|
||||||
|
|
||||||
export type Tag = {
|
export type Tag = {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { DARK_COLOUR_SHADE,
|
|||||||
const colours = Object.values (TAG_COLOUR)
|
const colours = Object.values (TAG_COLOUR)
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: ['./src/**/*.{html,js,ts,jsx,tsx}'],
|
content: ['./src/**/*.{html,js,ts,jsx,tsx,mdx}'],
|
||||||
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
|
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
|
||||||
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),
|
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),
|
||||||
...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`),
|
...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`),
|
||||||
@@ -24,4 +24,4 @@ export default {
|
|||||||
'rainbow-scroll': {
|
'rainbow-scroll': {
|
||||||
'0%': { backgroundPosition: '0% 50%' },
|
'0%': { backgroundPosition: '0% 50%' },
|
||||||
'100%': { backgroundPosition: '200% 50%' } } } } },
|
'100%': { backgroundPosition: '200% 50%' } } } } },
|
||||||
plugins: [] } satisfies Config
|
plugins: [require ('@tailwindcss/typography')] } satisfies Config
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { defineConfig } from 'vite'
|
import mdx from '@mdx-js/rollup'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig ({
|
export default defineConfig ({
|
||||||
plugins: [react()],
|
plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()],
|
||||||
resolve: { alias: { '@': path.resolve (__dirname, './src') } },
|
resolve: { alias: { '@': path.resolve (__dirname, './src') } },
|
||||||
server: { host: true,
|
server: { host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Reference in New Issue
Block a user