Compare commits

..

10 Commits

Author SHA1 Message Date
みてるぞ 4f5812d737 Merge remote-tracking branch 'origin/main' into feature/047 2026-05-04 16:24:34 +09:00
みてるぞ a5ea66d660 Merge remote-tracking branch 'origin/main' into feature/047 2026-05-03 03:32:55 +09:00
みてるぞ efaeb5325e Merge remote-tracking branch 'origin/main' into feature/047 2026-05-03 03:14:32 +09:00
みてるぞ e40f7a3620 #47 2026-03-27 01:01:47 +09:00
みてるぞ e8be071064 #47 2026-03-27 00:32:28 +09:00
みてるぞ be40b4bcc4 Merge remote-tracking branch 'origin/main' into feature/047 2026-03-26 23:52:57 +09:00
みてるぞ fb275b4763 #47 2026-03-26 23:52:44 +09:00
みてるぞ ef6219dcb1 #47 2026-03-26 00:01:29 +09:00
みてるぞ 04b01bf1c6 #47 2026-03-25 00:39:38 +09:00
みてるぞ 4c474d2bdf #47 2026-03-24 21:57:17 +09:00
37 changed files with 637 additions and 937 deletions
+15 -249
View File
@@ -44,7 +44,7 @@ class PostsController < ApplicationController
filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.preload(tags: [:materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -95,7 +95,7 @@ class PostsController < ApplicationController
end
def random
post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }])
.order('RAND()')
.first
return head :not_found unless post
@@ -104,7 +104,7 @@ class PostsController < ApplicationController
end
def show
post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
return head :not_found unless post
render json: PostRepr.base(post, current_user)
@@ -173,68 +173,33 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
force = bool?(:force)
merge = bool?(:merge)
return head :bad_request if force && merge
base_version_no = nil
base_version_no = parse_base_version_no unless force
title = params[:title].presence
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids
post = nil
conflict_json = nil
post = Post.find(params[:id].to_i)
ApplicationRecord.transaction do
post = Post.lock.find(params[:id].to_i)
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
base_version = nil
base_snapshot = nil
current_snapshot = nil
unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)
post.update!(title:, original_created_from:, original_created_before:)
base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post)
end
incoming_snapshot = post_incoming_snapshot(title:,
original_created_from:,
original_created_before:,
tag_names:,
parent_post_ids:)
normalised_tags = Tag.normalise_tags!(tag_names, with_tagme: false)
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)
snapshot_to_apply =
if force || post.version_no == base_version_no || current_snapshot == base_snapshot
incoming_snapshot
else
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
conflicts = changes.select { |change| change[:conflict] }
tags = post.tags.nico.to_a + normalised_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
if merge && conflicts.empty?
merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot)
else
conflict_json = post_conflict_json(post:,
base_version_no:,
base_snapshot:,
current_snapshot:,
incoming_snapshot:,
changes:,
conflicts:)
raise ActiveRecord::Rollback
end
end
sync_parent_posts!(post, parent_post_ids)
apply_post_snapshot!(post, snapshot_to_apply)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end
return render json: conflict_json, status: :conflict if conflict_json
post.reload
json = PostRepr.base(post, current_user)
json = post.as_json
json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok
rescue Tag::NicoTagNormalisationError
@@ -260,7 +225,7 @@ class PostsController < ApplicationController
pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user,
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
tag: [:materials, { tag_name: :wiki_page }])
events = []
pts.each do |pt|
@@ -439,203 +404,4 @@ class PostsController < ApplicationController
PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
end
end
def parse_base_version_no
version_no = Integer(params[:base_version_no], exception: false)
raise ArgumentError, 'base_version_no は必須です.' unless version_no&.positive?
version_no
end
def post_snapshot_from_version version
{ title: version.title,
original_created_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before),
tag_names: editable_tag_names_from_version(version),
parent_post_ids: snapshot_parent_post_ids_from_version(version) }
end
def editable_tag_names_from_version version
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
end
def post_snapshot_from_record post
{ title: post.title,
original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post),
parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
end
def editable_tag_names_from_post post
post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, parent_post_ids:
{ title:,
original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before),
tag_names: incoming_tag_names_for_snapshot(tag_names),
parent_post_ids: parent_post_ids.sort }
end
def snapshot_parent_post_ids_from_version version
if version.respond_to?(:parent_post_ids)
version.parent_post_ids.to_s.split.map { |id| id.to_i }.sort
elsif version.respond_to?(:parent_id) && version.parent_id
[version.parent_id]
else
[]
end
end
def snapshot_time value
return nil if value.blank?
value = Time.zone.parse(value.to_s) if value in String
value&.in_time_zone&.iso8601(6)
rescue ArgumentError, TypeError
value.to_s
end
def incoming_tag_names_for_snapshot raw_tag_names
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
end
def post_conflict_json post:, base_version_no:, base_snapshot:,
current_snapshot:, incoming_snapshot:, changes:, conflicts:
{ error: 'conflict',
message: '競合が発生しました.',
post_id: post.id,
base_version_no:,
current_version_no: post.version_no,
base: base_snapshot,
current: current_snapshot,
mine: incoming_snapshot,
changes:,
conflicts:,
mergeable: conflicts.empty? }
end
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
base_snapshot, current_snapshot, incoming_snapshot),
set_snapshot_change(:tag_names, 'タグ',
base_snapshot, current_snapshot, incoming_snapshot),
set_snapshot_change(:parent_post_ids, '親投稿',
base_snapshot, current_snapshot, incoming_snapshot)].compact
end
def scalar_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
base = base_snapshot[field]
current = current_snapshot[field]
mine = incoming_snapshot[field]
return nil if current == base && mine == base
{ field:, label:, base:, current:, mine:,
changed_by_current: current != base,
changed_by_me: mine != base,
conflict: scalar_snapshot_conflict?(base, current, mine) }
end
def scalar_snapshot_conflict? base, current, mine
current != base && mine != base && current != mine
end
def set_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
base = base_snapshot[field].to_a
current = current_snapshot[field].to_a
mine = incoming_snapshot[field].to_a
added_by_current = current - base
removed_by_current = base - current
added_by_me = mine - base
removed_by_me = base - mine
if (added_by_current.empty? &&
removed_by_current.empty? &&
added_by_me.empty? &&
removed_by_me.empty?)
return nil
end
{ field:, label:, base:, current:, mine:, added_by_current:, removed_by_current:,
added_by_me:, removed_by_me:,
changed_by_current: added_by_current.present? || removed_by_current.present?,
changed_by_me: added_by_me.present? || removed_by_me.present?,
conflict: set_snapshot_conflict?(added_by_current:,
removed_by_current:,
added_by_me:,
removed_by_me:) }
end
def set_snapshot_conflict? added_by_current:, removed_by_current:,
added_by_me:, removed_by_me:
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end
def apply_post_snapshot! post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title],
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
[:title, :original_created_from, :original_created_before].map {
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h.merge([:tag_names, :parent_post_ids].map {
[_1, merge_set_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h)
end
def merge_scalar_snapshot_value base, current, mine
return mine if current == base
return current if mine == base || current == mine
raise ArgumentError, '競合してゐる項目はマージできません.'
end
def merge_set_snapshot_value base, current, mine
base = base.to_a
current = current.to_a
mine = mine.to_a
added_by_current = current - base
removed_by_current = base - current
added_by_me = mine - base
removed_by_me = base - mine
merged = base + added_by_current + added_by_me
merged -= removed_by_current
merged -= removed_by_me
merged.uniq.sort
end
end
@@ -0,0 +1,39 @@
require 'digest'
class WikiAssetsController < ApplicationController
def index
page_id = params[:wiki_page_id].to_i
page = WikiPage.find_by(id: page_id)
return head :not_found unless page
render json: WikiAssetRepr.many(page.assets)
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
wiki_page_id = params[:wiki_page_id].to_i
page = WikiPage.find_by(id: wiki_page_id)
return head :not_found unless page
file = params[:file]
return head :bad_request if file.blank?
asset = nil
page.with_lock do
no = page.next_asset_no
alt_text = params[:alt_text].presence
sha256 = Digest::SHA256.file(file.tempfile.path).digest
asset = WikiAsset.new(wiki_page_id:, no:, alt_text:, sha256:, created_by_user: current_user)
asset.file.attach(file)
asset.save!
page.update!(next_asset_no: no + 1)
end
render json: WikiAssetRepr.base(asset)
end
end
@@ -109,7 +109,7 @@ class WikiPagesController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
title = params[:title]&.strip
title = params[:title].to_s.strip
body = params[:body].to_s
return head :unprocessable_entity if title.blank? || body.blank?
@@ -143,7 +143,14 @@ class WikiPagesController < ApplicationController
end
end
head :ok
message = params[:message].presence
Wiki::Commit.content!(page:,
body:,
created_user: current_user,
message:,
base_revision_id:)
render json: WikiPageRepr.base(page).merge(body:)
end
def search
-2
View File
@@ -28,8 +28,6 @@ class Post < ApplicationRecord
has_one_attached :thumbnail
attribute :version_no, :integer, default: 1
before_validation :normalise_url
validates :url, presence: true, uniqueness: true
-1
View File
@@ -2,7 +2,6 @@ class PostVersion < ApplicationRecord
include VersionRecord
belongs_to :post
belongs_to :parent, class_name: 'Post', optional: true
validates :url, presence: true
-2
View File
@@ -40,8 +40,6 @@ class Tag < ApplicationRecord
belongs_to :tag_name
delegate :wiki_page, to: :tag_name
attribute :version_no, :integer, default: 1
delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true
+12
View File
@@ -0,0 +1,12 @@
class WikiAsset < ApplicationRecord
belongs_to :wiki_page
belongs_to :created_by_user, class_name: 'User'
has_one_attached :file
validates :file, presence: true
def url
Rails.application.routes.url_helpers.rails_blob_url(file, only_path: true)
end
end
+2 -2
View File
@@ -13,9 +13,9 @@ class WikiPage < ApplicationRecord
foreign_key: :redirect_page_id,
dependent: :nullify
has_many :wiki_versions
has_many :assets, class_name: 'WikiAsset', dependent: :destroy
attribute :version_no, :integer, default: 1
has_many :wiki_versions
belongs_to :tag_name
validates :tag_name, presence: true
@@ -0,0 +1,16 @@
# frozen_string_literal: true
module WikiAssetRepr
BASE = { only: [:wiki_page_id, :no], methods: [:url] }.freeze
module_function
def base wiki_asset
wiki_asset.as_json(BASE)
end
def many wiki_assets
wiki_assets.map { |a| base(a) }
end
end
+10 -35
View File
@@ -16,20 +16,19 @@ class VersionRecorder
@record = record_class.unscoped.lock.find(@record.id)
latest = latest_version
validate_version_sequence! latest
if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
end
if @event_type == 'create' && latest
raise "#{ version_class.name } create event already exists"
end
attrs = snapshot_attributes
if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
return latest
end
return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
version = version_class.create!(
base_attributes(latest).merge(record_key => @record).merge(attrs))
update_record_version_no! version.version_no
version
version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs))
end
end
@@ -46,31 +45,7 @@ class VersionRecorder
created_by_user: @created_by_user }
end
def update_record_version_no! version_no
@record.update_columns version_no: version_no
@record.version_no = version_no
end
def validate_version_sequence! latest
if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
end
if @event_type == 'create' && latest
raise "#{ version_class.name } create event already exists"
end
return unless latest
if @record.version_no != latest.version_no
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
"but latest #{ version_class.name } version_no is #{ latest.version_no }")
end
end
def same_snapshot? version, attrs
attrs.all? { |k, v| version.public_send(k) == v }
end
def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v }
def validate_event_type!
return if EVENT_TYPES.include?(@event_type)
+2
View File
@@ -48,6 +48,8 @@ Rails.application.routes.draw do
get :exists
get :diff
end
resources :assets, controller: :wiki_assets, only: [:index, :create]
end
resources :posts, only: [:index, :show, :create, :update] do
@@ -0,0 +1,17 @@
class CreateWikiAssets < ActiveRecord::Migration[8.0]
def change
create_table :wiki_assets do |t|
t.references :wiki_page, null: false, foreign_key: true, index: false
t.integer :no, null: false
t.string :alt_text
t.column :sha256, 'binary(32)', null: false
t.references :created_by_user, null: false, foreign_key: { to_table: :users }
t.timestamps
end
add_index :wiki_assets, [:wiki_page_id, :sha256], unique: true
add_index :wiki_assets, [:wiki_page_id, :no], unique: true
add_column :wiki_pages, :next_asset_no, :integer, null: false, default: 1
end
end
@@ -1,27 +0,0 @@
class AddVersionNoToPosts < ActiveRecord::Migration[8.0]
def up
add_column :posts, :version_no, :integer
execute <<~SQL
UPDATE
posts
SET
version_no = (
SELECT
MAX(version_no)
FROM
post_versions
WHERE
post_id = posts.id)
SQL
change_column_null :posts, :version_no, false
add_check_constraint :posts, 'version_no > 0', name: 'chk_posts_version_no_positive'
end
def down
remove_check_constraint :posts, name: 'chk_posts_version_no_positive'
remove_column :posts, :version_no
end
end
@@ -1,37 +0,0 @@
class AddVersionNoToTags < ActiveRecord::Migration[8.0]
def up
add_column :tags, :version_no, :integer
execute <<~SQL
UPDATE
tags
SET
version_no = (
CASE category
WHEN 'nico' THEN
(SELECT
MAX(version_no)
FROM
nico_tag_versions
WHERE
tag_id = tags.id)
ELSE
(SELECT
MAX(version_no)
FROM
tag_versions
WHERE
tag_id = tags.id)
END)
SQL
change_column_null :tags, :version_no, false
add_check_constraint :tags, 'version_no > 0', name: 'chk_tags_version_no_positive'
end
def down
remove_check_constraint :tags, name: 'chk_tags_version_no_positive'
remove_column :tags, :version_no
end
end
@@ -1,27 +0,0 @@
class AddVersionNoToWikiPages < ActiveRecord::Migration[8.0]
def up
add_column :wiki_pages, :version_no, :integer
execute <<~SQL
UPDATE
wiki_pages
SET
version_no = (
SELECT
MAX(version_no)
FROM
wiki_versions
WHERE
wiki_page_id = wiki_pages.id)
SQL
change_column_null :wiki_pages, :version_no, false
add_check_constraint :wiki_pages, 'version_no > 0', name: 'chk_wiki_pages_version_no_positive'
end
def down
remove_check_constraint :wiki_pages, name: 'chk_wiki_pages_version_no_positive'
remove_column :wiki_pages, :version_no
end
end
+1 -7
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) 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
@@ -186,10 +186,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
t.datetime "original_created_from"
t.datetime "original_created_before"
t.datetime "updated_at", null: false
t.integer "version_no", null: false
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -264,10 +262,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -373,12 +369,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", 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
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+16
View File
@@ -1409,6 +1409,22 @@ RSpec.describe 'Posts API', type: :request do
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),
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)
+191
View File
@@ -0,0 +1,191 @@
require 'digest'
require 'rails_helper'
require 'stringio'
RSpec.describe 'WikiAssets API', type: :request do
def dummy_upload(content = 'dummy-image', filename: 'dummy.png', content_type: 'image/png')
Rack::Test::UploadedFile.new(StringIO.new(content),
content_type,
original_filename: filename)
end
let(:member) { create(:user, :member, name: 'member user') }
let(:guest) { create(:user, name: 'guest user') }
let!(:tag_name) { TagName.create!(name: 'spec_wiki_asset_page') }
let!(:page) do
WikiPage.create!(tag_name: tag_name, created_user: member, updated_user: member).tap do |p|
Wiki::Commit.content!(page: p, body: 'init', created_user: member, message: 'init')
end
end
describe 'GET /wiki/:wiki_page_id/assets' do
subject(:do_request) do
get "/wiki/#{wiki_page_id}/assets"
end
let(:wiki_page_id) { page.id }
let!(:asset) do
WikiAsset.new(wiki_page: page,
no: 1,
alt_text: 'spec alt',
sha256: Digest::SHA256.digest('asset-1'),
created_by_user: member).tap do |record|
record.file.attach(dummy_upload('asset-1'))
record.save!
end
end
context 'when wiki page exists' do
it 'returns assets for the page' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to be_an(Array)
expect(json.size).to eq(1)
expect(json.first).to include(
'wiki_page_id' => page.id,
'no' => 1)
end
it 'does not include assets from other pages' do
other_tag_name = TagName.create!(name: 'spec_other_wiki_asset_page')
other_page = WikiPage.create!(tag_name: other_tag_name,
created_user: member,
updated_user: member)
Wiki::Commit.content!(page: other_page, body: 'other', created_user: member, message: 'other')
WikiAsset.new(wiki_page: other_page,
no: 1,
alt_text: 'other alt',
sha256: Digest::SHA256.digest('asset-2'),
created_by_user: member).tap do |record|
record.file.attach(dummy_upload('asset-2', filename: 'other.png'))
record.save!
end
do_request
expect(response).to have_http_status(:ok)
expect(json.size).to eq(1)
expect(json.first['wiki_page_id']).to eq(page.id)
end
end
context 'when wiki page does not exist' do
let(:wiki_page_id) { 999_999_999 }
it 'returns 404' do
do_request
expect(response).to have_http_status(:not_found)
end
end
end
describe 'POST /wiki/:wiki_page_id/assets' do
subject(:do_request) do
post "/wiki/#{wiki_page_id}/assets", params: params
end
let(:wiki_page_id) { page.id }
let(:params) do
{ file: dummy_upload(upload_content),
alt_text: 'uploaded alt' }
end
let(:upload_content) { 'uploaded-image-binary' }
context 'when not logged in' do
it 'returns 401' do
sign_out
do_request
expect(response).to have_http_status(:unauthorized)
end
end
context 'when logged in but not member' do
it 'returns 403' do
sign_in_as(guest)
do_request
expect(response).to have_http_status(:forbidden)
end
end
context 'when wiki page does not exist' do
let(:wiki_page_id) { 999_999_999 }
it 'returns 404' do
sign_in_as(member)
do_request
expect(response).to have_http_status(:not_found)
end
end
context 'when file is blank' do
let(:params) { { alt_text: 'uploaded alt' } }
it 'returns 400' do
sign_in_as(member)
do_request
expect(response).to have_http_status(:bad_request)
end
end
context 'when success' do
before do
sign_in_as(member)
end
it 'creates asset, attaches file, increments next_asset_no, and returns json' do
expect { do_request }
.to change(WikiAsset, :count).by(1)
expect(response).to have_http_status(:ok)
asset = WikiAsset.order(:id).last
expect(asset.wiki_page_id).to eq(page.id)
expect(asset.no).to eq(1)
expect(asset.alt_text).to eq('uploaded alt')
expect(asset.sha256).to eq(Digest::SHA256.digest(upload_content))
expect(asset.created_by_user_id).to eq(member.id)
expect(asset.file).to be_attached
expect(asset.file.download).to eq(upload_content)
expect(page.reload.next_asset_no).to eq(2)
expect(json).to include(
'wiki_page_id' => page.id,
'no' => 1,
'url' => asset.url
)
end
it 'uses the next page-local number when assets already exist' do
existing = WikiAsset.new(wiki_page: page,
no: 1,
alt_text: 'existing alt',
sha256: Digest::SHA256.digest('existing'),
created_by_user: member)
existing.file.attach(dummy_upload('existing', filename: 'existing.png'))
existing.save!
page.update!(next_asset_no: 2)
do_request
expect(response).to have_http_status(:ok)
asset = WikiAsset.order(:id).last
expect(asset.no).to eq(2)
expect(page.reload.next_asset_no).to eq(3)
expect(json).to include(
'wiki_page_id' => page.id,
'no' => 2,
'url' => asset.url
)
end
end
end
end
+1
View File
@@ -113,6 +113,7 @@ RSpec.describe 'Wiki API', type: :request do
page_id = json.fetch('id')
expect(json.fetch('title')).to eq('TestPage')
expect(json.fetch('body')).to eq("a\nb\nc")
created_page = WikiPage.find(page_id)
version = created_page.wiki_versions.order(:version_no).last
+10 -15
View File
@@ -8,7 +8,6 @@ import { BrowserRouter,
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
@@ -139,21 +138,17 @@ export default (() => {
return (
<>
<RouteBlockerOverlay/>
<BrowserRouter>
<DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
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/>
</DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
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/>
</BrowserRouter>
</>)
}) satisfies FC
+18 -71
View File
@@ -3,12 +3,11 @@ import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import Label from '@/components/common/Label'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { updatePost } from '@/lib/posts'
import { apiPut } from '@/lib/api'
import type { FC, FormEvent } from 'react'
import type { FC } from 'react'
import type { Post, Tag } from '@/types'
@@ -33,7 +32,6 @@ type Props = { post: Post
export default (({ post, onSave }: Props) => {
const [disabled, setDisabled] = useState (false)
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] =
@@ -43,14 +41,16 @@ export default (({ post, onSave }: Props) => {
const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title)
const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => {
const handleSubmit = async () => {
try
{
const data = await updatePost (...args)
const data = await apiPut<Post> (
`/posts/${ post.id }`,
{ title, tags, parent_post_ids: parentPostIds,
original_created_from: originalCreatedFrom,
original_created_before: originalCreatedBefore },
{ headers: { 'Content-Type': 'multipart/form-data' } })
onSave ({ ...post,
versionNo: data.versionNo,
title: data.title,
tags: data.tags,
parentPosts: data.parentPosts,
@@ -60,55 +60,12 @@ export default (({ post, onSave }: Props) => {
originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' })
}
catch (e)
catch
{
const response = (e as any)?.response
if (response?.status !== 409)
{
toast ({ description: '更新はできなかったよ……' })
return
}
const action = await dialogue.choice ({
title: '競合が発生しました.',
description: (
<div>
<p></p>
<p>?</p>
</div>),
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []),
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })
if (action === 'merge')
{
// TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true })
return
}
if (action === 'overwrite')
{
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true })
return
}
toast ({ description: '更新はできなかったよ……' })
}
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()
setDisabled (true)
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
setDisabled (false)
}
useEffect (() => {
setTags(tagsToStr (post.tags))
}, [post])
@@ -118,12 +75,10 @@ export default (({ post, onSave }: Props) => {
{/* タイトル */}
<div>
<Label></Label>
<input
type="text"
disabled={disabled}
className="w-full border rounded p-2"
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>
<input type="text"
className="w-full border rounded p-2"
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>
</div>
{/* 親投稿 */}
@@ -131,32 +86,24 @@ export default (({ post, onSave }: Props) => {
<Label>稿</Label>
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* タグ */}
<PostFormTagsArea
disabled={disabled}
tags={tags}
setTags={setTags}/>
<PostFormTagsArea tags={tags} setTags={setTags}/>
{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
disabled={disabled}
originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}/>
{/* 送信 */}
<Button
type="submit"
disabled={disabled}
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
<Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
</Button>
</div>)
+6 -8
View File
@@ -7,7 +7,7 @@ import Label from '@/components/common/Label'
import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api'
import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react'
import type { FC, SyntheticEvent } from 'react'
import type { Tag } from '@/types'
@@ -31,13 +31,12 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
`${ value.slice (0, start) }${ text }${ value.slice (end) }`
type Props =
& { tags: string
setTags: (tags: string) => void }
& ComponentPropsWithoutRef<'textarea'>
type Props = {
tags: string
setTags: (tags: string) => void }
export default (({ tags, setTags, ...rest }: Props) => {
export default (({ tags, setTags }: Props) => {
const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
@@ -88,8 +87,7 @@ export default (({ tags, setTags, ...rest }: Props) => {
onBlur={() => {
setFocused (false)
setSuggestionsVsbl (false)
}}
{...rest}/>
}}/>
{focused && (
<TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0
@@ -5,15 +5,13 @@ import { Button } from '@/components/ui/button'
import type { FC } from 'react'
type Props = {
disabled?: boolean
originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void }
export default (({ disabled,
originalCreatedFrom,
export default (({ originalCreatedFrom,
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore }: Props) => (
@@ -23,7 +21,6 @@ export default (({ disabled,
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
@@ -43,7 +40,6 @@ export default (({ disabled,
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
@@ -55,7 +51,6 @@ export default (({ disabled,
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
@@ -63,7 +58,6 @@ export default (({ disabled,
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
+3 -3
View File
@@ -36,12 +36,12 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new' },
{ name: '全体履歴', to: '/posts/changes' },
{ name: '履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'マスタ', to: '/tags' },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ name: '全体履歴', to: '/tags/changes' },
{ name: '履歴', to: '/tags/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
{ component: <Separator/>, visible: tagFlg },
{ name: `広場 (${ postCount || 0 })`,
@@ -53,7 +53,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search', visible: false },
{ name: '追加', to: '/materials/new' },
{ name: '全体履歴', to: '/materials/changes', visible: false },
{ name: '履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
+3 -36
View File
@@ -1,42 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGFM from 'remark-gfm'
import PrefetchLink from '@/components/PrefetchLink'
import { wikiKeys } from '@/lib/queryKeys'
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
import { fetchWikiPages } from '@/lib/wiki'
import WikiMarkdown from '@/components/WikiMarkdown'
import type { FC } from 'react'
import type { Components } from 'react-markdown'
type Props = { title: string
body?: string }
const mdComponents = { a: (({ href, children }) => (
['/', '.'].some (e => href?.startsWith (e))
? <PrefetchLink to={href!}>{children}</PrefetchLink>
: (
<a href={href}
target="_blank"
rel="noopener noreferrer">
{children}
</a>))) } as const satisfies Components
export default (({ title, body }: Props) => {
const { data } = useQuery ({
enabled: Boolean (body),
queryKey: wikiKeys.index ({ }),
queryFn: () => fetchWikiPages ({ }) })
const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length)
const remarkPlugins = useMemo (
() => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames])
return (
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
</ReactMarkdown>)
}) satisfies FC<Props>
export default (({ title, body }: Props) =>
<WikiMarkdown title={title} body={body ?? ''}/>) satisfies FC<Props>
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react'
import MdEditor from 'react-markdown-editor-lite'
import WikiMarkdown from '@/components/WikiMarkdown'
import Label from '@/components/common/Label'
import { apiPost } from '@/lib/api'
import type { FC } from 'react'
import type { WikiAsset } from '@/types'
type Props = {
title: string
body: string
onSubmit: (title: string, body: string) => void
id?: number | null }
export default (({ title: initTitle, body: initBody, onSubmit, id }: Props) => {
const forEdit = id != null
const [title, setTitle] = useState<string> (initTitle)
const [body, setBody] = useState<string> (initBody)
useEffect (() => {
setTitle (initTitle)
setBody (initBody)
}, [initTitle, initBody])
const handleImageUpload = async (file: File) => {
if (!(forEdit))
throw new Error ('画像は Wiki 作成前に追加することができません.')
const formData = new FormData
formData.append ('file', file)
const asset = await apiPost<WikiAsset> (
`/wiki/${ id }/assets`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
return asset.url
}
return (
<>
{/* タイトル */}
{/* TODO: タグ補完 */}
<div>
<Label></Label>
<input
type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* 本文 */}
<div>
<Label></Label>
<MdEditor
value={body}
style={{ height: '500px' }}
renderHTML={text => <WikiMarkdown body={text} preview/>}
onChange={({ text }) => setBody (text)}
onImageUpload={handleImageUpload}/>
</div>
{/* 送信 */}
<button
onClick={() => onSubmit (title, body)}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
{forEdit ? '編輯' : '追加'}
</button>
</>)
}) satisfies FC<Props>
+77
View File
@@ -0,0 +1,77 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGFM from 'remark-gfm'
import PrefetchLink from '@/components/PrefetchLink'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import { wikiKeys } from '@/lib/queryKeys'
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
import { fetchWikiPages } from '@/lib/wiki'
import type { FC } from 'react'
import type { Components } from 'react-markdown'
type Props = {
title?: string
body: string
preview?: boolean }
const makeComponents = (preview = false) => (
{ h1: ({ children }) => <SectionTitle>{children}</SectionTitle>,
h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>,
ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>,
ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>,
a: ({ href, children }) => {
if (!(href))
return <>{children}</>
if (!(preview) && ['/', '.'].some (e => href.startsWith (e)))
return <PrefetchLink to={href}>{children}</PrefetchLink>
const ext = /^(?:https?:)?\/\//.test (href)
return (
<a href={href}
target={ext ? '_blank' : undefined}
rel={ext ? 'noopener noreferrer' : undefined}>
{children}
</a>)
},
img: (({ src, alt }) => (
<img src={src ?? ''}
alt={alt ?? ''}
className="max-w-[240px] max-h-[320px]"/>)),
} as const satisfies Components)
export default (({ title, body, preview = false }: Props) => {
const { data } = useQuery ({
queryKey: wikiKeys.index ({ }),
queryFn: () => fetchWikiPages ({ }) })
const pageNames = useMemo (
() => (data ?? []).map ((page) => page.title).sort ((a, b) => b.length - a.length),
[data])
const remarkPlugins = useMemo (
() => [() => remarkWikiAutoLink (pageNames), remarkGFM],
[pageNames])
const components = useMemo (
() => makeComponents (preview),
[preview])
return (
<ReactMarkdown
components={components}
remarkPlugins={remarkPlugins}>
{body
|| (title
? ('このページは存在しません。'
+`[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`)
: '')}
</ReactMarkdown>)
}) satisfies FC<Props>
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
import type { FC, FocusEvent } from 'react'
const pad = (n: number): string => n.toString ().padStart (2, '0')
@@ -18,14 +18,14 @@ const toDateTimeLocalValue = (d: Date) => {
}
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
type Props = {
value?: string
onChange?: (isoUTC: string | null) => void
className?: string
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void }
export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
export default (({ value, onChange, className, onBlur }: Props) => {
const [local, setLocal] = useState ('')
useEffect (() => {
@@ -42,6 +42,5 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
setLocal (v)
onChange?.(v ? (new Date (v)).toISOString () : null)
}}
onBlur={onBlur}
{...rest}/>)
onBlur={onBlur}/>)
}) satisfies FC<Props>
@@ -1,184 +0,0 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle } from '@/components/ui/dialog'
import type { FC, ReactNode } from 'react'
type DialogueVariant = 'default' | 'danger'
type ConfirmOptions = { title: string
description?: ReactNode
confirmText?: string
cancelText?: string
variant?: DialogueVariant }
type AlertOptions = { title: string
description?: ReactNode
okText?: string }
type Choice<T extends string> = { value: T
label: string
variant?: DialogueVariant }
type ChoiceOptions<T extends string> = { title: string
description?: ReactNode
choices: Choice<T>[]
cancelText?: string }
type DialogueRequest =
| { id: number
kind: 'confirm'
options: ConfirmOptions
resolve: (value: boolean) => void }
| { id: number
kind: 'alert'
options: AlertOptions
resolve: () => void }
| { id: number
kind: 'choice'
options: ChoiceOptions<string>
resolve: (value: string | null) => void }
type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
alert: (options: AlertOptions) => Promise<void>
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> }
const DialogueContext = createContext<DialogueAPI | null> (null)
let nextDialogueId = 1
type Props = { children: ReactNode }
export default (({ children }: Props) => {
const [queue, setQueue] = useState<DialogueRequest[]> ([])
const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
const id = nextDialogueId
++nextDialogueId
setQueue (q => [...q, { ...request, id } as DialogueRequest])
}, [])
const closeActive = useCallback ((result?: unknown) => {
setQueue (q => {
const [active, ...rest] = q
if (!(active))
return rest
switch (active.kind)
{
case 'confirm':
active.resolve (Boolean (result))
break
case 'alert':
active.resolve ()
break
case 'choice':
active.resolve ((result ?? null) as string | null)
break
}
return rest
})
}, [])
const api = useMemo<DialogueAPI> (() => ({
confirm: options => new Promise<boolean> (resolve => {
push ({ kind: 'confirm', options, resolve })
}),
alert: options => new Promise<void> (resolve => {
push ({ kind: 'alert', options, resolve })
}),
choice: options => new Promise (resolve => {
push ({ kind: 'choice',
options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void })
}) }), [push])
const active = queue[0]
return (
<DialogueContext.Provider value={api}>
{children}
<Dialog
open={Boolean (active)}
onOpenChange={open => {
if (!(open))
closeActive (active?.kind !== 'confirm' && null)
}}>
{active && (
<DialogContent>
<DialogTitle>{active.options.title}</DialogTitle>
{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}
<DialogFooter>
{active.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeActive (false)}>
{active.options.cancelText ?? '取消'}
</Button>
<Button
variant={(active.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (true)}>
{active.options.confirmText ?? '確定'}
</Button>
</>)}
{active.kind === 'alert' && (
<Button onClick={() => closeActive ()}>
{active.options.okText ?? '確定'}
</Button>)}
{active.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeActive (null)}>
{active.options.cancelText ?? '取消'}
</Button>
{active.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</DialogContent>)}
</Dialog>
</DialogueContext.Provider>)
}) satisfies FC<Props>
export const useDialogue = () => {
const dialogue = useContext (DialogueContext)
if (!(dialogue))
throw new Error ('useDialogue must be used inside DialogueProvider')
return dialogue
}
+11 -13
View File
@@ -37,27 +37,25 @@ const DialogContent = React.forwardRef<
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn (
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
className={cn(
'fixed left-[50%] top-[50%] z-50 w-[90%] grid max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'duration-200',
'gap-4 border bg-gray-300/80 dark:bg-gray-700/80',
'p-6 shadow-lg duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2',
'data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2',
'data-[state=open]:slide-in-from-top-[48%] rounded-lg',
className)}
{...props}
>
{children}
<DialogPrimitive.Close
className={cn (
'absolute right-4 top-4 rounded-full p-1',
'text-muted-foreground opacity-70 transition-opacity',
'hover:bg-accent hover:text-accent-foreground hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2')}>
<X className="h-4 w-4"/>
<span className="sr-only">Close</span>
<DialogPrimitive.Close className="absolute right-4 top-4 bg-red-500 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
+28 -50
View File
@@ -6,56 +6,6 @@
@layer base
{
:root
{
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
}
.dark
{
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 62.8% 45%;
--destructive-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
body
{
@apply overflow-x-clip;
@@ -104,6 +54,34 @@ body
min-height: 100dvh;
}
h1
{
font-size: 3.2em;
line-height: 1.1;
}
button
{
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover
{
border-color: #646cff;
}
button:focus,
button:focus-visible
{
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light)
{
:root
+1 -25
View File
@@ -1,4 +1,4 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
import { apiDelete, apiGet, apiPost } from '@/lib/api'
import type { FetchPostsParams, Post, PostVersion } from '@/types'
@@ -42,30 +42,6 @@ export const fetchPostChanges = async (
page, limit } })
export const updatePost = async (
post: { id: number
title: string | null
tags: string
parentPostIds: string
originalCreatedFrom: string | null
originalCreatedBefore: string | null },
{ baseVersionNo, force, merge }: {
baseVersionNo?: number
force?: boolean
merge?: boolean }
) =>
await apiPut<Post> (
`/posts/${ post.id }`,
{ title: post.title,
tags: post.tags,
parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore },
{ params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
force: force ? '1' : '0',
merge: merge ? '1' : '0' } })
export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => {
await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`)
}
+43 -44
View File
@@ -11,14 +11,13 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { fetchPostChanges, updatePost } from '@/lib/posts'
import { apiPut } from '@/lib/api'
import { fetchPostChanges } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, MouseEvent } from 'react'
import type { PostVersion } from '@/types'
import type { FC } from 'react'
const renderDiff = (diff: { current: string | null; prev: string | null }) => (
@@ -63,45 +62,6 @@ export default (() => {
const qc = useQueryClient ()
const handleRevert = async (e: MouseEvent<HTMLAnchorElement>, change: PostVersion) => {
e.preventDefault ()
if (!(confirm (`${ change.title.current || change.url.current }』を版 ${
change.versionNo } \nよろしいですか?`)))
return
try
{
const id = change.postId
const title = change.title.current
const tags =
change.tags
.filter (t => t.type !== 'removed')
.map (t => t.name)
.filter (t => t.slice (0, 5) !== 'nico:')
.join (' ')
const parentPostIds =
(change.parentPosts ?? [])
.filter (p => p.type !== 'removed')
.map (p => p.id)
.join (' ')
const originalCreatedFrom = change.originalCreatedFrom.current
const originalCreatedBefore = change.originalCreatedBefore.current
await updatePost ({ id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ force: true })
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '差戻しました.' })
}
catch
{
toast ({ description: '差戻に失敗……' })
}
}
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
@@ -271,7 +231,46 @@ export default (() => {
{dateString (change.createdAt)}
</td>
<td className="p-2">
<a href="#" onClick={async e => await handleRevert (e, change)}>
<a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(confirm (
`${ change.title.current
|| change.url.current } ${
change.versionNo } \nよろしいですか?`)))
return
try
{
await apiPut (
`/posts/${ change.postId }`,
{ title: change.title.current,
tags: change.tags
.filter (t => t.type !== 'removed')
.map (t => t.name)
.filter (t => t.slice (0, 5) !== 'nico:')
.join (' '),
parent_post_ids:
(change.parentPosts ?? [])
.filter (p => p.type !== 'removed')
.map (p => p.id)
.join (' '),
original_created_from:
change.originalCreatedFrom.current,
original_created_before:
change.originalCreatedBefore.current })
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '差戻しました.' })
}
catch
{
toast ({ description: '差戻に失敗……' })
}
}}>
</a>
</td>
+7 -33
View File
@@ -1,10 +1,9 @@
import { useQueryClient } from '@tanstack/react-query'
import MarkdownIt from 'markdown-it'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite'
import { useParams, useNavigate } from 'react-router-dom'
import WikiEditForm from '@/components/WikiEditForm'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
@@ -18,8 +17,6 @@ import type { FC } from 'react'
import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt
type Props = { user: User | null }
@@ -37,7 +34,7 @@ export default (({ user }: Props) => {
const [loading, setLoading] = useState (true)
const [title, setTitle] = useState ('')
const handleSubmit = async () => {
const handleSubmit = async (title: string, body: string) => {
const formData = new FormData ()
formData.append ('title', title)
formData.append ('body', body)
@@ -46,8 +43,6 @@ export default (({ user }: Props) => {
{
await apiPut (`/wiki/${ id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
qc.setQueryData (wikiKeys.show (title, { }),
(prev: WikiPage) => ({ ...prev, title, body }))
qc.invalidateQueries ({ queryKey: wikiKeys.root })
toast ({ title: '投稿成功!' })
navigate (`/wiki/${ title }`)
@@ -77,32 +72,11 @@ export default (({ user }: Props) => {
<h1 className="text-2xl font-bold mb-2">Wiki </h1>
{loading ? 'Loading...' : (
<>
{/* タイトル */}
{/* TODO: タグ補完 */}
<div>
<label className="block font-semibold mb-1"></label>
<input type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* 本文 */}
<div>
<label className="block font-semibold mb-1"></label>
<MdEditor value={body}
style={{ height: '500px' }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>
</div>
{/* 送信 */}
<button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
</button>
</>)}
<WikiEditForm
title={title}
body={body}
onSubmit={handleSubmit}
id={Number (id)}/>)}
</div>
</MainArea>)
}) satisfies FC<Props>
+12 -34
View File
@@ -1,21 +1,19 @@
import MarkdownIt from 'markdown-it'
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite'
import { useLocation, useNavigate } from 'react-router-dom'
import WikiEditForm from '@/components/WikiEditForm'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import Forbidden from '@/pages/Forbidden'
import 'react-markdown-editor-lite/lib/index.css'
import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt
type Props = { user: User | null }
@@ -26,13 +24,12 @@ export default ({ user }: Props) => {
const location = useLocation ()
const navigate = useNavigate ()
const qc = useQueryClient ()
const query = new URLSearchParams (location.search)
const titleQuery = query.get ('title') ?? ''
const [title, setTitle] = useState (titleQuery)
const [body, setBody] = useState ('')
const handleSubmit = async () => {
const handleSubmit = async (title: string, body: string) => {
const formData = new FormData
formData.append ('title', title)
formData.append ('body', body)
@@ -40,7 +37,8 @@ export default ({ user }: Props) => {
try
{
const data = await apiPost<WikiPage> ('/wiki', formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
{ headers: { 'Content-Type': 'multipart/form-data' } })
qc.invalidateQueries ({ queryKey: wikiKeys.root })
toast ({ title: '投稿成功!' })
navigate (`/wiki/${ data.title }`)
}
@@ -58,30 +56,10 @@ export default ({ user }: Props) => {
<div className="max-w-xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold mb-2"> Wiki </h1>
{/* タイトル */}
{/* TODO: タグ補完 */}
<div>
<label className="block font-semibold mb-1"></label>
<input type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* 本文 */}
<div>
<label className="block font-semibold mb-1"></label>
<MdEditor value={body}
style={{ height: '500px' }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>
</div>
{/* 送信 */}
<button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
</button>
<WikiEditForm
title={titleQuery}
body=""
onSubmit={handleSubmit}/>
</div>
</MainArea>)
}
+5 -2
View File
@@ -121,7 +121,6 @@ export type Platform = typeof PLATFORMS[number]
export type Post = {
id: number
versionNo: number
url: string
title: string | null
thumbnail: string | null
@@ -147,7 +146,6 @@ export type PostTagChange = {
export type PostVersion = {
postId: number
latestVersionNo: number
versionNo: number
eventType: 'create' | 'update' | 'discard' | 'restore'
title: { current: string | null; prev: string | null }
@@ -225,6 +223,11 @@ export type User = {
export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior]
export type WikiAsset = {
wikiPageId: number
no: number
url: string }
export type WikiPage = {
id: number
title: string
+1 -16
View File
@@ -19,22 +19,7 @@ export default {
'rainbow-scroll': 'rainbow-scroll .25s linear infinite' },
colors: {
red: { 925: '#5f1414',
975: '#230505' },
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: { DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))' },
secondary: { DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))' },
destructive: { DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))' },
muted: { DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))' },
accent: { DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))' } },
975: '#230505' } },
keyframes: {
'rainbow-scroll': {
'0%': { backgroundPosition: '0% 50%' },