Compare commits

..

12 Commits

Author SHA1 Message Date
みてるぞ dc54f9cbb5 Merge pull request 'フロントのテスト追加 (#155)' (#350) from feature/155 into main
Reviewed-on: #350
2026-05-13 22:03:05 +09:00
みてるぞ 78143363c9 #155 2026-05-13 21:49:40 +09:00
みてるぞ 0a13c00f37 #155 2026-05-13 20:42:25 +09:00
みてるぞ add60cb413 #155 2026-05-11 03:32:47 +09:00
みてるぞ fb761b199d さらに修正 (#346) (#349)
Merge remote-tracking branch 'origin/main' into feature/346

#346

#346

Merge remote-tracking branch 'origin/main' into feature/346

#346

#346

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #349
2026-05-11 02:46:08 +09:00
みてるぞ 73152f2934 ちょっと修正 (#346) (#348)
#346

Merge remote-tracking branch 'origin/main' into feature/346

#346

#346

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #348
2026-05-11 02:43:08 +09:00
みてるぞ 2de7e13a8a Codex 用ファイル追加 (#346) (#347)
#346

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #347
2026-05-11 02:30:55 +09:00
みてるぞ e03cc01109 投稿排他 (#171) (#345)
#171

#171

#171

#171

#171

#171

#171

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #345
2026-05-10 11:16:49 +09:00
みてるぞ b47cdc7ad7 BAN の実装 (#327) (#342)
#327

#327

#327

#327

Merge remote-tracking branch 'origin/main' into feature/327

#327

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #342
2026-05-04 16:22:13 +09:00
みてるぞ 52aa1615b6 ニジラー詳細ページ作成 (#63) (#341)
#63

#63

#63

#63

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #341
2026-05-04 03:37:12 +09:00
みてるぞ dceed1caa1 親投稿機能 (#46) (#339)
Merge remote-tracking branch 'origin/main' into feature/046

#46

#46

#46

#46

#46

#46

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #339
2026-05-03 03:21:35 +09:00
みてるぞ 5002859fc8 YouTube の自動同期 (#314) (#340)
#314

#314

#314

#314

#314

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #340
2026-05-02 17:56:14 +09:00
168 changed files with 7963 additions and 710 deletions
+35
View File
@@ -0,0 +1,35 @@
## 背景
なぜ必要か。
## 対象範囲
- backend:
- frontend:
- docs:
- migration:
## やること
- [ ]
## 受け入れ条件
- [ ]
## 実行すべき確認
- [ ] `cd backend && bundle exec rspec`
- [ ] `cd frontend && npm run build`
- [ ] `cd frontend && npm run lint`
## 禁止事項
- unrelated refactor はしない
- 既存 API response shape を壊さない
- 認証・認可・BAN を弱めない
## Codex への指示
この issue を読んで実装してください。
不明点があれば、実装前に調査結果と選択肢を提示してください。
+143
View File
@@ -0,0 +1,143 @@
# AGENTS.md
## Project overview
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
- Backend: Rails API under `backend/`.
- Frontend: React + TypeScript + Vite under `frontend/`.
- Docs: lightweight command notes under `docs/`.
- There is no README or Makefile at the repository root as of this inspection.
## Stack
- Backend: Ruby `3.2.2` from `backend/.ruby-version`, Rails `~> 8.0.2`.
- Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`, `factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`, `aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`.
- Frontend: React `^19.1.0`, TypeScript `~5.8.3`, Vite `^6.3.5`.
- Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS, Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and Zustand.
## Main directories
- `backend/app/controllers`: Rails API controllers.
- `backend/app/models`: Active Record models.
- `backend/app/representations`: API response representation classes.
- `backend/app/services`: domain services such as version recording, wiki commit, YouTube sync, and similarity calculation.
- `backend/config/routes.rb`: API routes.
- `backend/db/migrate`: migrations.
- `backend/db/schema.rb`: current schema snapshot.
- `backend/lib/tasks`: custom Rake tasks.
- `backend/spec`: RSpec tests.
- `backend/test`: Rails minitest files that still exist in the tree.
- `frontend/src/App.tsx`: frontend route definitions and initial user setup.
- `frontend/src/pages`: page-level React components.
- `frontend/src/components`: shared and feature components.
- `frontend/src/lib`: API client helpers, query keys, prefetchers, and domain helpers.
- `frontend/src/stores`: Zustand stores.
- `docs/commands.md`: command notes.
## Commands
Only list commands that are backed by files inspected in this repository.
### Backend
The following binstubs exist under `backend/bin`:
```sh
cd backend
bin/setup
bin/dev
bin/rails
bin/rake
bin/rubocop
bin/brakeman
bin/kamal
bin/thrust
```
Common Rails/Rake usage through existing binstubs:
```sh
cd backend
bin/rails db:prepare
bin/rails db:migrate
bin/rails routes
bin/rails server
bin/rake
bin/rubocop
bin/brakeman
```
RSpec is present in `Gemfile` and `.rspec` exists:
```sh
cd backend
bundle exec rspec
```
### Frontend
The following npm scripts exist in `frontend/package.json`:
```sh
cd frontend
npm run dev
npm run build
npm run lint
npm run preview
```
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs `node scripts/generate-sitemap.js`.
Do not write or report `npm test` as a repository command unless a `test` script is added to `frontend/package.json`.
## Coding style
- Prefer precise, minimal changes.
- Do not flatter or over-explain.
- Explain risks directly.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
- Ruby: never put a space before method-call parentheses.
- Ruby: do not use `%w` or `%i`.
- TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid.
- Do not add production dependencies without explicit approval.
## Backend rules
- Inspect existing routes, controllers, models, services, and specs before editing backend behavior.
- For API behavior changes, add or update request specs under `backend/spec/requests`.
- Prefer RSpec for new backend tests; existing minitest files under `backend/test` do not make minitest the default for new coverage.
- Do not weaken authentication, BAN user checks, or IP BAN checks.
- Preserve the `X-Transfer-Code` user identification flow unless the task explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency, wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema.
## Frontend rules
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`; avoid ad hoc query key arrays.
- Encode URL path-segment values with `encodeURIComponent`.
- React hooks must be called unconditionally.
- Keep page-level code under `frontend/src/pages` and shared UI/feature code under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions.
## Codex workflow
- First inspect existing patterns; do not invent new architecture when a local convention exists.
- Keep changes scoped to the requested issue.
- Do not scan or summarize dependency/generated/runtime directories such as `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication behavior, inspect the related request specs and service objects.
- If frontend code changes, run the existing frontend verification commands that apply: `npm run build` and `npm run lint`.
- If backend code changes, run the relevant RSpec command; for broad backend changes, run `bundle exec rspec`.
- If a verification command cannot be run or fails, report the exact command and failure.
## Completion criteria
A task is complete only when:
- implementation is complete,
- relevant verification commands pass, or failures are clearly explained,
- unrelated files are not changed,
- migrations and schema are consistent when schema changes are made,
- user-facing behavior is documented when needed.
+147
View File
@@ -0,0 +1,147 @@
# backend/AGENTS.md
## Scope
These rules apply to work under `backend/`.
This is a Rails API app using Active Record, RSpec, request specs, service objects, representation classes, and version tables for post/tag/wiki history.
## Commands
Use commands backed by files and dependencies in this directory:
```sh
bin/setup
bin/dev
bin/rails
bin/rake
bin/rubocop
bin/brakeman
bundle exec rspec
```
Common checks:
```sh
bundle exec rspec
bin/rubocop
bin/brakeman
```
Common Rails commands:
```sh
bin/rails db:prepare
bin/rails db:migrate
bin/rails routes
bin/rails server
```
After backend behavior changes, run the relevant RSpec files. For broad backend changes, run:
```sh
bundle exec rspec
```
If a command cannot be run or fails, report the exact command and failure.
## Rails structure
- `app/controllers`: API controllers.
- `app/models`: Active Record models and concerns.
- `app/representations`: JSON response shaping.
- `app/services`: domain services such as version recorders, wiki commit, YouTube sync, and similarity calculation.
- `config/routes.rb`: public API routes.
- `db/migrate`: migrations.
- `db/schema.rb`: schema snapshot.
- `lib/tasks`: custom Rake tasks.
- `spec`: RSpec tests.
Before changing behavior, inspect the matching route, controller, model, service, representation, and spec.
## Ruby style
- Prefer precise, minimal changes.
- Use single quotes unless interpolation or escaping makes double quotes better.
- Do not put a space before Ruby method-call parentheses.
- Do not use `%w` or `%i` in new Ruby code.
- Keep comments short and useful; avoid narrating obvious code.
- Do not add production dependencies without approval.
## Authentication and authorization
- Authentication is handled through the `X-Transfer-Code` header in `ApplicationController#authenticate_user`.
- `current_user` is set by looking up `User.inheritance_code`.
- Do not bypass or weaken the `X-Transfer-Code` flow unless the task explicitly changes authentication.
- Unauthenticated write actions should return `:unauthorized` consistently with existing controllers.
- Role checks use `User` enum roles: `guest`, `member`, and `admin`.
- Use `current_user.gte_member?` for member-or-admin write permissions where existing controllers do so.
- Use `current_user.admin?` only for admin-only paths, such as tag child relationship changes.
- Do not replace role checks with looser presence checks.
## BAN and IP BAN
- `ApplicationController` runs these before actions in order:
- `reject_banned_ip_address!`
- `authenticate_user`
- `reject_banned_user!`
- User and IP bans use `banned_at`, not a boolean `banned` column.
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
- Do not weaken BAN or IP BAN behavior.
- If changing request authentication or controller before actions, add or update request specs covering banned users and banned IP addresses.
## RSpec
- Prefer RSpec for new backend tests.
- Put API behavior coverage under `spec/requests`.
- Put model behavior under `spec/models`.
- Put service behavior under `spec/services`.
- Put Rake task coverage under `spec/tasks`.
- `spec/rails_helper.rb` loads `spec/support/**/*.rb`.
- Request specs include `AuthHelper` and `JsonHelper`.
- `AuthHelper#sign_in_as(user)` stubs `ApplicationController#current_user`; use it when matching existing request spec style.
- Add or update request specs for API behavior changes, especially status codes, permissions, response shape, and version conflict behavior.
## Migrations
- Keep migrations and `db/schema.rb` consistent.
- Use reversible migrations where practical; otherwise define explicit `up` and `down`.
- For data backfills inside migrations, follow the existing pattern of defining migration-local `ActiveRecord::Base` classes with `self.table_name`.
- Preserve existing indexes, foreign keys, check constraints, and null constraints.
- Be careful with MySQL-specific options already present in migrations, such as `after:`.
- Do not edit old migrations just to change current behavior unless explicitly requested; add a new migration.
## Version tables
- Versioned records include posts, tags, nico tags, and wiki pages.
- Current records have `version_no`; version tables have positive `version_no` with unique indexes scoped to the parent record.
- Version event types are `create`, `update`, `discard`, and `restore`.
- Version rows are readonly through the `VersionRecord` concern.
- Use the existing recorder services instead of manually inserting version rows in application code:
- `PostVersionRecorder`
- `TagVersionRecorder`
- `NicoTagVersionRecorder`
- `WikiVersionRecorder`
- `TagVersioning`
- `VersionRecorder` locks the current record, validates sequence consistency, skips unchanged update snapshots, creates the next version row, and updates the record `version_no`.
- Do not update versioned records without considering whether a version snapshot must be created.
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and `merge` semantics and cover conflicts in request specs.
## Domain cautions
- Posts have tag snapshots, parent post implications, original-created ranges, viewed state, and version conflict behavior.
- Tags have canonical names, aliases through `TagName`, categories, parent implications, discard behavior, and version snapshots.
- Nico tags have separate relation/version behavior; do not treat them like normal editable tags without checking existing code.
- Wiki pages involve page content, revisions/history, version rows, title/tag-name behavior, and diff/restore paths.
- Materials, theatres, and comments have user and permission checks; inspect the controller before changing them.
## API responses
- Use representation classes under `app/representations` when existing endpoints do.
- Keep response keys consistent with existing JSON contracts; frontend code expects camelCase conversion client-side, while Rails params and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions: `:unauthorized` for no user, `:forbidden` for insufficient role or banned user, `:not_found` for missing records, and `:unprocessable_entity` for validation failures.
## Files to avoid in routine work
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency directories unless explicitly needed.
- Do not modify generated schema or migration output without the corresponding migration when schema changes are made.
@@ -1,14 +1,16 @@
class ApplicationController < ActionController::API
before_action :reject_banned_ip_address!
before_action :authenticate_user
before_action :reject_banned_user!
def current_user
@current_user
end
def current_user = @current_user
private
def authenticate_user
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
return if code.blank?
@current_user = User.find_by(inheritance_code: code)
end
@@ -22,4 +24,17 @@ class ApplicationController < ActionController::API
s.in?(['', '1', 'true', 'on', 'yes'])
end
end
def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?
head :forbidden
end
def reject_banned_user!
return unless current_user&.banned?
head :forbidden
end
end
@@ -33,7 +33,7 @@ class NicoTagsController < ApplicationController
return head :bad_request unless tag.nico?
linked_tag_names = params[:tags].to_s.split
linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false,
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
return head :bad_request if linked_tags.any? { |t| t.nico? }
+308 -21
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: [:materials, { tag_name: :wiki_page }])
.preload(tags: [:deerjikists, :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: [:materials, { tag_name: :wiki_page }])
post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.order('RAND()')
.first
return head :not_found unless post
@@ -104,12 +104,12 @@ class PostsController < ApplicationController
end
def show
post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
return head :not_found unless post
render json: PostRepr.base(post, current_user)
.merge(tags: build_tag_tree_for(post.tags),
related: post.related(limit: 20))
related: PostRepr.many(post.related(limit: 20)))
end
def create
@@ -123,28 +123,36 @@ class PostsController < ApplicationController
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 = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail)
post.thumbnail.attach(thumbnail) if thumbnail.present?
ApplicationRecord.transaction do
post.save!
tags = Tag.normalise_tags(tag_names)
tags = Tag.normalise_tags!(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
sync_parent_posts!(post, parent_post_ids)
post.resized_thumbnail!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end
post.reload
render json: PostRepr.base(post), status: :created
rescue ActiveRecord::RecordInvalid
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError
head :bad_request
rescue ArgumentError => e
render json: { errors: [e.message] }, status: :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
def viewed
@@ -165,35 +173,76 @@ 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 = parse_base_version_no
return head :bad_request if !(force) && !(base_version_no)
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 = Post.find(params[:id].to_i)
post = nil
conflict_json = nil
ApplicationRecord.transaction do
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post = Post.lock.find(params[:id].to_i)
post.update!(title:, original_created_from:, original_created_before:)
base_version = nil
base_snapshot = nil
current_snapshot = nil
unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)
normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false)
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)
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:)
tags = post.tags.nico.to_a + normalised_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
PostVersionRecorder.record!(post:, event_type: :update, 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] }
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
apply_post_snapshot!(post, snapshot_to_apply)
end
return render json: conflict_json, status: :conflict if conflict_json
post.reload
json = post.as_json
json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok
rescue ActiveRecord::RecordInvalid
render json: post.errors, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError
head :bad_request
rescue ArgumentError => e
render json: { errors: [e.message] }, status: :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
def changes
@@ -211,7 +260,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: [:materials, { tag_name: :wiki_page }])
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
events = []
pts.each do |pt|
@@ -353,4 +402,242 @@ class PostsController < ApplicationController
root_ids.filter_map { |id| build_node.call(id, []) }
end
def parse_parent_post_ids
raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids)
params[:parent_post_ids].to_s.split.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id
}.uniq
end
def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id)
post.errors.add(:base, '自分自身を親投稿にはできません.')
raise ActiveRecord::RecordInvalid, post
end
existing_ids = Post.where(id: parent_post_ids).pluck(:id)
missing_ids = parent_post_ids - existing_ids
if missing_ids.present?
post.errors.add(:base, "存在しない親投稿 ID があります: #{ missing_ids.join(' ') }")
raise ActiveRecord::RecordInvalid, post
end
current_ids = post.parent_posts.pluck(:id)
ids_to_add = parent_post_ids - current_ids
ids_to_remove = current_ids - parent_post_ids
PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all
ids_to_add.each do |parent_post_id|
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)
if version_no&.positive?
version_no
else
nil
end
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
+49 -3
View File
@@ -1,3 +1,7 @@
require 'net/http'
require 'uri'
class TagsController < ApplicationController
def index
post_id = params[:post]
@@ -182,7 +186,8 @@ class TagsController < ApplicationController
.find_by(id: params[:id])
return head :not_found unless tag
render json: DeerjikistRepr.many(tag.deerjikists)
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
end
def deerjikists_by_name
@@ -194,7 +199,31 @@ class TagsController < ApplicationController
.find_by(tag_names: { name: })
return head :not_found unless tag
render json: DeerjikistRepr.many(tag.deerjikists)
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
end
def update_deerjikists
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
return head :not_found unless tag
ApplicationRecord.transaction do
tag.deerjikists = []
params[:_json].each do
platform = _1[:platform]
code = normalise_deerjikist_code(platform, _1[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
deerjikist.save!
end
end
render json: DeerjikistRepr.many(tag.reload.deerjikists)
end
def materials_by_name
@@ -374,7 +403,7 @@ class TagsController < ApplicationController
end
def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags(parent_names, with_tagme: false,
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)
@@ -391,4 +420,21 @@ class TagsController < ApplicationController
TagImplication.create!(tag:, parent_tag:)
end
end
def normalise_deerjikist_code platform, code
return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }"
html = Net::HTTP.get(URI(url))
canonical = html[
/<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})"/,
1]
return canonical if canonical
html[/"channelId":"(UC[a-zA-Z0-9_-]{22})"/, 1] || html[/\bUC[a-zA-Z0-9_-]{22}\b/]
rescue
nil
end
end
+1 -5
View File
@@ -1,9 +1,6 @@
class UsersController < ApplicationController
def create
return head :unprocessable_entity if request.remote_ip.blank?
user = nil
User.transaction do
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
attach_ip_address!(user)
@@ -17,8 +14,7 @@ class UsersController < ApplicationController
def verify
user = User.find_by(inheritance_code: params[:code])
return render json: { valid: false } unless user
return head :unprocessable_entity if request.remote_ip.blank?
return head :forbidden if user.banned?
attach_ip_address!(user)
+4 -1
View File
@@ -1,7 +1,10 @@
class IpAddress < ApplicationRecord
validates :ip_address, presence: true, length: { maximum: 16 }
validates :banned, inclusion: { in: [true, false] }
has_many :user_ips, dependent: :destroy
has_many :users, through: :user_ips
def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end
+30 -3
View File
@@ -1,7 +1,6 @@
class Post < ApplicationRecord
require 'mini_magick'
belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, inverse_of: :post
@@ -13,8 +12,24 @@ class Post < ApplicationRecord
has_many :post_similarities, dependent: :delete_all
has_many :post_versions
has_many :parent_post_implications,
class_name: 'PostImplication',
foreign_key: :post_id,
dependent: :destroy,
inverse_of: :post
has_many :parents, through: :parent_post_implications, source: :parent_post
has_many :child_post_implications,
class_name: 'PostImplication',
foreign_key: :parent_post_id,
dependent: :destroy,
inverse_of: :parent_post
has_many :children, through: :child_post_implications, source: :post
has_one_attached :thumbnail
attribute :version_no, :integer, default: 1
before_validation :normalise_url
validates :url, presence: true, uniqueness: true
@@ -22,17 +37,29 @@ class Post < ApplicationRecord
validate :validate_original_created_range
validate :url_must_be_http_url
def parent_posts = parents
def child_posts = children
def sibling_posts
parent_post_ids = parent_posts.order(:id).pluck(:id)
parent_post_ids.to_h { [_1, PostImplication.where(parent_post: _1).map(&:post)] }
end
def as_json options = { }
super(options).merge({ thumbnail: thumbnail.attached? ?
super(options).merge(thumbnail: thumbnail.attached? ?
Rails.application.routes.url_helpers.rails_blob_url(
thumbnail, only_path: false) :
nil })
nil)
rescue
super(options).merge(thumbnail: nil)
end
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
def related limit: nil
ids = post_similarities.order(cos: :desc)
ids = ids.limit(limit) if limit
+19
View File
@@ -0,0 +1,19 @@
class PostImplication < ApplicationRecord
self.primary_key = :post_id, :parent_post_id
belongs_to :post, inverse_of: :parent_post_implications
belongs_to :parent_post, class_name: 'Post', inverse_of: :child_post_implications
validates :post_id, presence: true, uniqueness: { scope: :parent_post_id }
validates :parent_post_id, presence: true
validate :parent_post_mustnt_be_itself
private
def parent_post_mustnt_be_itself
if parent_post_id == post_id
errors.add :parent_post_id, '親投稿に同じ投稿を設定することはできません.'
end
end
end
+5 -1
View File
@@ -40,6 +40,8 @@ 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
@@ -79,6 +81,8 @@ class Tag < ApplicationRecord
def material_id = materials.first&.id
def has_deerjikists = deerjikists.present?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
@@ -86,7 +90,7 @@ class Tag < ApplicationRecord
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)
def self.normalise_tags tag_names, with_tagme: true,
def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
+5 -1
View File
@@ -4,7 +4,6 @@ class User < ApplicationRecord
validates :name, length: { maximum: 255 }
validates :inheritance_code, presence: true, length: { maximum: 64 }
validates :role, presence: true, inclusion: { in: roles.keys }
validates :banned, inclusion: { in: [true, false] }
has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
@@ -19,5 +18,10 @@ class User < ApplicationRecord
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
def viewed?(post) = user_post_views.exists?(post_id: post.id)
def gte_member? = member? || admin?
def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end
+2
View File
@@ -15,6 +15,8 @@ class WikiPage < ApplicationRecord
has_many :wiki_versions
attribute :version_no, :integer, default: 1
belongs_to :tag_name
validates :tag_name, presence: true
validates :body, presence: true
+2 -1
View File
@@ -2,7 +2,8 @@
module PostRepr
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE },
methods: [:parent_posts, :child_posts, :sibling_posts] }.freeze
module_function
+1 -1
View File
@@ -3,7 +3,7 @@
module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki, :material_id] }.freeze
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
module_function
@@ -24,7 +24,7 @@ class PostVersionRecorder < VersionRecorder
url: @record.url,
thumbnail_base: @record.thumbnail_base,
tags: @record.snapshot_tag_names.join(' '),
parent_id: @record.parent_id,
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before }
end
+35 -10
View File
@@ -16,19 +16,20 @@ class VersionRecorder
@record = record_class.unscoped.lock.find(@record.id)
latest = latest_version
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
validate_version_sequence!(latest)
attrs = snapshot_attributes
return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
return latest
end
version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs))
version = version_class.create!(
base_attributes(latest).merge(record_key => @record).merge(attrs))
update_record_version_no!(version.version_no)
version
end
end
@@ -45,7 +46,31 @@ class VersionRecorder
created_by_user: @created_by_user }
end
def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v }
def update_record_version_no! version_no
@record.update_columns(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 validate_event_type!
return if EVENT_TYPES.include?(@event_type)
+1
View File
@@ -24,6 +24,7 @@ Rails.application.routes.draw do
patch '', action: :update
get :deerjikists
put :deerjikists, action: :update_deerjikists
end
end
@@ -0,0 +1,24 @@
class CreatePostImplications < ActiveRecord::Migration[8.0]
def up
create_table :post_implications, primary_key: [:post_id, :parent_post_id] do |t|
t.references :post, null: false, foreign_key: true, index: false
t.references :parent_post, null: false, foreign_key: { to_table: :posts }
t.timestamps
t.check_constraint 'post_id <> parent_post_id',
name: 'chk_post_implications_no_self'
end
add_column :post_versions, :parent_post_ids, :text, null: false, after: :parent_id
remove_column :post_versions, :parent_id, :bigint
remove_reference :posts, :parent, foreign_key: { to_table: :posts }
end
def down
add_reference :posts, :parent, foreign_key: { to_table: :posts }, after: :thumbnail_base
add_column :post_versions, :parent_id, :bigint, after: :post_id
remove_column :post_versions, :parent_post_ids, :text
drop_table :post_implications
end
end
@@ -0,0 +1,16 @@
class RenameBannedToBannedAtInUsersAndIpAddresses < ActiveRecord::Migration[8.0]
def up
[:users, :ip_addresses].each do
add_column _1, :banned_at, :datetime, after: :banned
add_index _1, :banned_at
remove_column _1, :banned
end
end
def down
[:ip_addresses, :users].each do
add_column _1, :banned, :boolean, null: false, default: false, after: :banned_at
remove_column _1, :banned_at
end
end
end
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,27 @@
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
+23 -9
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_04_26_120600) do
ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) 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
@@ -50,9 +50,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false
t.boolean "banned", default: false, null: false
t.datetime "banned_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_ip_addresses_on_banned_at"
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end
@@ -119,6 +120,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
end
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "target_post_id", null: false
@@ -155,13 +165,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000
t.text "tags", null: false
t.bigint "parent_id"
t.text "parent_post_ids", null: false
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"
@@ -172,15 +181,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
t.string "title"
t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000
t.bigint "parent_id"
t.bigint "uploaded_user_id"
t.datetime "created_at", null: false
t.datetime "original_created_from"
t.datetime "original_created_before"
t.datetime "updated_at", null: false
t.index ["parent_id"], name: "index_posts_on_parent_id"
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|
@@ -255,8 +264,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) 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|
@@ -326,9 +337,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
t.string "name"
t.string "inheritance_code", limit: 64, null: false
t.string "role", null: false
t.boolean "banned", default: false, null: false
t.datetime "banned_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_users_on_banned_at"
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -361,10 +373,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) 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|
@@ -428,6 +442,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tags", "posts"
@@ -435,9 +451,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "posts", column: "parent_id"
add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "posts", column: "parent_id"
add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users"
add_foreign_key "tag_implications", "tags"
+10
View File
@@ -0,0 +1,10 @@
FactoryBot.define do
factory :ip_address do
ip_address { IPAddr.new('203.0.113.10').hton }
banned_at { nil }
trait :banned do
banned_at { Time.current }
end
end
end
+12 -3
View File
@@ -1,15 +1,24 @@
FactoryBot.define do
factory :user do
name { "test-user" }
name { nil }
inheritance_code { SecureRandom.uuid }
role { "guest" }
role { 'guest' }
banned_at { nil }
trait :guest do
role { 'guest' }
end
trait :member do
role { "member" }
role { 'member' }
end
trait :admin do
role { 'admin' }
end
trait :banned do
banned_at { Time.current }
end
end
end
@@ -0,0 +1,51 @@
require 'rails_helper'
RSpec.describe PostImplication, type: :model do
let!(:post_record) do
Post.create!(
title: 'post',
url: 'https://example.com/post-implication-post'
)
end
let!(:parent_post) do
Post.create!(
title: 'parent post',
url: 'https://example.com/post-implication-parent'
)
end
it 'is valid with post and parent_post' do
implication = described_class.new(
post: post_record,
parent_post:
)
expect(implication).to be_valid
end
it 'does not allow same post as parent_post' do
implication = described_class.new(
post: post_record,
parent_post: post_record
)
expect(implication).not_to be_valid
expect(implication.errors[:parent_post_id]).to be_present
end
it 'does not allow duplicate pair' do
described_class.create!(
post: post_record,
parent_post:
)
duplicate = described_class.new(
post: post_record,
parent_post:
)
expect(duplicate).not_to be_valid
expect(duplicate.errors[:post_id]).to be_present
end
end
+1 -1
View File
@@ -19,7 +19,7 @@ RSpec.describe PostVersion, type: :model do
url: post_record.url,
thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '),
parent: post_record.parent,
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before,
created_at: Time.current,
+1 -1
View File
@@ -161,7 +161,7 @@ RSpec.describe Tag, type: :model do
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent: post.parent,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,
+689 -98
View File
@@ -10,11 +10,53 @@ RSpec.describe 'Posts API', type: :request do
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
end
def create_nico_tag!(name)
Tag.find_or_create_by_tag_name!(name, category: :nico)
end
def dummy_upload
# 中身は何でもいい(加工処理はスタブしてる)
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
end
def post_write_params params = { }
{ parent_post_ids: '' }.merge(params)
end
def create_parent_post! title:, url:
Post.create!(title:, url:)
end
def create_post_version_for!(post)
version =
PostVersion.create!(
post:,
version_no: 1,
event_type: 'create',
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: post.snapshot_tag_names.join(' '),
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
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)
post.update_columns(version_no: version.version_no) if post.has_attribute?(:version_no)
post.version_no = version.version_no if post.respond_to?(:version_no=)
version
end
def post_update_params(post, params = { })
base_version =
post.post_versions.order(version_no: :desc).first ||
create_post_version_for!(post.reload)
post_write_params({ base_version_no: base_version.version_no }.merge(params))
end
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
@@ -457,6 +499,65 @@ RSpec.describe 'Posts API', type: :request do
expect(json).to have_key('viewed')
expect([true, false]).to include(json['viewed'])
end
context 'when post has parent, child, and sibling posts' do
let!(:parent_post) do
create_parent_post!(
title: 'shared parent post',
url: 'https://example.com/shared-parent-post'
)
end
let!(:child_post) do
Post.create!(
title: 'child post',
url: 'https://example.com/show-child-post'
)
end
let!(:sibling_post) do
Post.create!(
title: 'sibling post',
url: 'https://example.com/show-sibling-post'
)
end
before do
PostImplication.create!(
post: post_record,
parent_post:
)
PostImplication.create!(
post: child_post,
parent_post: post_record
)
PostImplication.create!(
post: sibling_post,
parent_post:
)
end
it 'returns parent_posts, child_posts, and sibling_posts' do
get "/posts/#{post_record.id}"
expect(response).to have_http_status(:ok)
parent_ids = json.fetch('parent_posts').map { |p| p.fetch('id') }
child_ids = json.fetch('child_posts').map { |p| p.fetch('id') }
expect(parent_ids).to include(parent_post.id)
expect(child_ids).to include(child_post.id)
sibling_posts_by_parent = json.fetch('sibling_posts')
siblings = sibling_posts_by_parent.fetch(parent_post.id.to_s)
sibling_ids = siblings.map { |p| p.fetch('id') }
expect(sibling_ids).to include(post_record.id)
expect(sibling_ids).to include(sibling_post.id)
end
end
end
context 'when post does not exist' do
@@ -475,25 +576,28 @@ RSpec.describe 'Posts API', type: :request do
it '401 when not logged in' do
sign_out
post '/posts', params: { title: 't', url: 'https://example.com/x', tags: 'a', thumbnail: dummy_upload }
post '/posts', params: post_write_params(title: 't', url: 'https://example.com/x', tags: 'a',
thumbnail: dummy_upload)
expect(response).to have_http_status(:unauthorized)
end
it '403 when not member' do
sign_in_as(create(:user, role: 'guest'))
post '/posts', params: { title: 't', url: 'https://example.com/x', tags: 'a', thumbnail: dummy_upload }
post '/posts', params: post_write_params(title: 't', url: 'https://example.com/x', tags: 'a',
thumbnail: dummy_upload)
expect(response).to have_http_status(:forbidden)
end
it '201 and creates post + tags when member' do
sign_in_as(member)
post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post',
url: 'https://example.com/new',
tags: 'spec_tag', # 既存タグ名を投げる
thumbnail: dummy_upload
}
)
expect(response).to have_http_status(:created)
expect(json).to include('id', 'title', 'url')
@@ -507,12 +611,12 @@ RSpec.describe 'Posts API', type: :request do
it '201 and creates post + tags when member and tags have aliases' do
sign_in_as(member)
post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post',
url: 'https://example.com/new',
tags: 'manko', # 既存タグ名を投げる
thumbnail: dummy_upload
}
)
expect(response).to have_http_status(:created)
expect(json).to include('id', 'title', 'url')
@@ -533,13 +637,14 @@ RSpec.describe 'Posts API', type: :request do
it 'return 400' do
sign_in_as(member)
post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post',
url: 'https://example.com/nico_tag',
url: 'https://example.com/nico-tag-post',
tags: 'nico:nico_tag',
thumbnail: dummy_upload }
thumbnail: dummy_upload
)
expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:bad_request), response.body
end
end
@@ -547,11 +652,11 @@ RSpec.describe 'Posts API', type: :request do
it 'returns 422' do
sign_in_as(member)
post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post',
url: ' ',
tags: 'spec_tag', # 既存タグ名を投げる
thumbnail: dummy_upload }
thumbnail: dummy_upload)
expect(response).to have_http_status(:unprocessable_entity)
end
@@ -561,16 +666,156 @@ RSpec.describe 'Posts API', type: :request do
it 'returns 422' do
sign_in_as(member)
post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post',
url: 'ぼざクリタグ広場',
tags: 'spec_tag', # 既存タグ名を投げる
thumbnail: dummy_upload
}
thumbnail: dummy_upload)
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'when parent_post_ids is provided' do
let!(:parent_post_1) do
create_parent_post!(
title: 'parent post 1',
url: 'https://example.com/parent-post-1'
)
end
let!(:parent_post_2) do
create_parent_post!(
title: 'parent post 2',
url: 'https://example.com/parent-post-2'
)
end
it 'creates post implications for parent posts' do
sign_in_as(member)
expect {
post '/posts', params: {
title: 'child post',
url: 'https://example.com/child-post',
tags: 'spec_tag',
parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}",
thumbnail: dummy_upload }
}.to change(PostImplication, :count).by(2)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
expect(created_post.parent_posts.order(:id).pluck(:id)).to eq(
[parent_post_1.id, parent_post_2.id].sort
)
expect(PostImplication.exists?(
post_id: created_post.id,
parent_post_id: parent_post_1.id
)).to be(true)
expect(PostImplication.exists?(
post_id: created_post.id,
parent_post_id: parent_post_2.id
)).to be(true)
end
it 'deduplicates parent_post_ids' do
sign_in_as(member)
expect {
post '/posts', params: post_write_params(
title: 'dedup child post',
url: 'https://example.com/dedup-child-post',
tags: 'spec_tag',
parent_post_ids: "#{parent_post_1.id} #{parent_post_1.id}",
thumbnail: dummy_upload
)
}.to change(PostImplication, :count).by(1)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
expect(created_post.parent_posts.pluck(:id)).to eq([parent_post_1.id])
end
it 'records parent_post_ids in post version' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'versioned child post',
url: 'https://example.com/versioned-child-post',
tags: 'spec_tag',
parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}",
thumbnail: dummy_upload
)
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.parent_post_ids.split.map(&:to_i)).to eq(
[parent_post_1.id, parent_post_2.id].sort
)
end
end
context 'when parent_post_ids is missing' do
it 'returns 422' do
sign_in_as(member)
expect {
post '/posts', params: {
title: 'missing parent_post_ids',
url: 'https://example.com/missing-parent-post-ids',
tags: 'spec_tag',
thumbnail: dummy_upload }
}.not_to change(Post, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end
context 'when parent_post_ids includes invalid token' do
it 'returns 422 and does not create post' do
sign_in_as(member)
expect {
post '/posts', params: post_write_params(
title: 'invalid parent ids',
url: 'https://example.com/invalid-parent-ids',
tags: 'spec_tag',
parent_post_ids: 'abc',
thumbnail: dummy_upload
)
}.not_to change(Post, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end
context 'when parent_post_ids includes nonexistent post id' do
it 'returns 422 and does not create post implication' do
sign_in_as(member)
expect {
post '/posts', params: post_write_params(
title: 'missing parent post',
url: 'https://example.com/missing-parent-post',
tags: 'spec_tag',
parent_post_ids: '999999999',
thumbnail: dummy_upload
)
}.not_to change(PostImplication, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end
end
describe 'PUT /posts/:id' do
@@ -578,33 +823,33 @@ RSpec.describe 'Posts API', type: :request do
it '401 when not logged in' do
sign_out
put "/posts/#{post_record.id}", params: { title: 'updated', tags: 'spec_tag' }
put "/posts/#{post_record.id}", params: post_update_params(
post_record, title: 'updated', tags: 'spec_tag')
expect(response).to have_http_status(:unauthorized)
end
it '403 when not member' do
sign_in_as(create(:user, role: 'guest'))
put "/posts/#{post_record.id}", params: { title: 'updated', tags: 'spec_tag' }
put "/posts/#{post_record.id}", params: post_update_params(
post_record, title: 'updated', tags: 'spec_tag')
expect(response).to have_http_status(:forbidden)
end
it '200 and updates title + resync tags when member' do
sign_in_as(member)
# 追加で別タグも作って、更新時に入れ替わることを見る
tn2 = TagName.create!(name: 'spec_tag_2')
Tag.create!(tag_name: tn2, category: :general)
put "/posts/#{post_record.id}", params: {
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title',
tags: 'spec_tag_2'
}
tags: 'spec_tag_2')
expect(response).to have_http_status(:ok)
expect(json).to have_key('tags')
expect(json['tags']).to be_an(Array)
# show と同様、update 後レスポンスもツリー形式
names = json['tags'].map { |n| n['name'] }
expect(names).to include('spec_tag_2')
end
@@ -619,12 +864,402 @@ RSpec.describe 'Posts API', type: :request do
it 'return 400' do
sign_in_as(member)
put "/posts/#{ post_record.id }", params: {
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title',
tags: 'nico:nico_tag' }
tags: 'nico:nico_tag')
expect(response).to have_http_status(:bad_request), response.body
end
end
context 'when parent_post_ids is provided' do
let!(:old_parent_post) do
create_parent_post!(
title: 'old parent post',
url: 'https://example.com/old-parent-post'
)
end
let!(:new_parent_post_1) do
create_parent_post!(
title: 'new parent post 1',
url: 'https://example.com/new-parent-post-1'
)
end
let!(:new_parent_post_2) do
create_parent_post!(
title: 'new parent post 2',
url: 'https://example.com/new-parent-post-2'
)
end
before do
PostImplication.create!(
post: post_record,
parent_post: old_parent_post
)
end
it 'replaces parent posts' do
sign_in_as(member)
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}")
expect(response).to have_http_status(:ok)
expect(post_record.reload.parent_posts.order(:id).pluck(:id)).to eq(
[new_parent_post_1.id, new_parent_post_2.id].sort
)
expect(PostImplication.exists?(
post_id: post_record.id,
parent_post_id: old_parent_post.id
)).to be(false)
end
it 'clears parent posts when parent_post_ids is blank' do
sign_in_as(member)
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: ''
)
expect(response).to have_http_status(:ok)
expect(post_record.reload.parent_posts).to be_empty
end
it 'records changed parent_post_ids in post version' do
sign_in_as(member)
create_post_version_for!(post_record.reload)
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}"
)
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.parent_post_ids.split.map(&:to_i)).to eq(
[new_parent_post_1.id, new_parent_post_2.id].sort
)
end
end
context 'when parent_post_ids is missing' do
it 'returns 422' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
put "/posts/#{post_record.id}", params: {
base_version_no: base_version.version_no,
title: 'updated title',
tags: 'spec_tag' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end
context 'when parent_post_ids includes invalid token' do
it 'returns 422 and does not change parent posts' do
sign_in_as(member)
parent_post = create_parent_post!(
title: 'valid parent post',
url: 'https://example.com/valid-parent-post'
)
PostImplication.create!(
post: post_record,
parent_post:
)
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: 'abc'
)
expect(response).to have_http_status(:unprocessable_entity)
expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id])
end
end
context 'when parent_post_ids includes nonexistent post id' do
it 'returns 422 and does not change parent posts' do
sign_in_as(member)
parent_post = create_parent_post!(
title: 'existing parent post',
url: 'https://example.com/existing-parent-post'
)
PostImplication.create!(
post: post_record,
parent_post:
)
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: '999999999'
)
expect(response).to have_http_status(:unprocessable_entity)
expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id])
end
end
context 'when parent_post_ids includes self id' do
it 'returns 422 and does not create self implication' do
sign_in_as(member)
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: post_record.id.to_s
)
expect(response).to have_http_status(:unprocessable_entity)
expect(PostImplication.exists?(
post_id: post_record.id,
parent_post_id: post_record.id
)).to be(false)
end
end
context 'with optimistic locking' do
let!(:no_deerjikist_tag) { Tag.no_deerjikist }
before do
PostTag.create!(post: post_record, tag: no_deerjikist_tag)
end
it '400 when base_version_no is missing without force' do
sign_in_as(member)
put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag')
expect(response).to have_http_status(:bad_request)
end
it '400 when force and merge are both true' do
sign_in_as(member)
put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag',
force: '1',
merge: '1')
expect(response).to have_http_status(:bad_request)
end
it '409 when scalar fields are changed both by current and incoming updates' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
post_record.update!(title: 'updated by other user')
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated by me',
tags: "spec_tag #{Tag.no_deerjikist.name}")
expect(response).to have_http_status(:conflict)
expect(json.fetch('error')).to eq('conflict')
expect(json.fetch('base_version_no')).to eq(base_version.version_no)
expect(json.fetch('current_version_no')).to eq(2)
expect(json.fetch('mergeable')).to be(false)
conflict_fields = json.fetch('conflicts').map { |change| change.fetch('field') }
expect(conflict_fields).to include('title')
expect(post_record.reload.title).to eq('updated by other user')
end
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
current_tag = Tag.find_or_create_by_tag_name!('current_added_tag', category: :general)
PostTag.create!(post: post_record, tag: current_tag, created_user: member)
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: post_record.title,
tags: "spec_tag #{Tag.no_deerjikist.name} incoming_added_tag")
expect(response).to have_http_status(:conflict)
expect(json.fetch('mergeable')).to be(true)
tag_change = json.fetch('changes').find { |change| change.fetch('field') == 'tag_names' }
expect(tag_change).to be_present
expect(tag_change.fetch('conflict')).to be(false)
expect(tag_change.fetch('added_by_current')).to include('current_added_tag')
expect(tag_change.fetch('added_by_me')).to include('incoming_added_tag')
end
it 'merges non-conflicting stale tag changes when merge is true' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
current_tag = Tag.find_or_create_by_tag_name!('current_merge_tag', category: :general)
PostTag.create!(post: post_record, tag: current_tag, created_user: member)
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: post_record.title,
tags: "spec_tag #{Tag.no_deerjikist.name} incoming_merge_tag",
merge: '1')
expect(response).to have_http_status(:ok)
names = post_record.reload.tags.map(&:name)
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).to include('current_merge_tag')
expect(names).to include('incoming_merge_tag')
end
it 'does not conflict when only nico tags changed after the base version' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
nico_tag = create_nico_tag!('nico:optimistic_lock_nico')
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(post_record.reload.version_no).to eq(2)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: post_record.title,
tags: "spec_tag #{ Tag.no_deerjikist.name }")
expect(response).to have_http_status(:ok)
names = post_record.reload.tags.map(&:name)
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).to include(nico_tag.name)
end
it 'keeps nico tags even when they are not included in PUT tags' do
sign_in_as(member)
nico_tag = create_nico_tag!('nico:readonly_update_nico')
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated title',
tags: "spec_tag #{ Tag.no_deerjikist.name }")
expect(response).to have_http_status(:ok)
names = post_record.reload.tags.map(&:name)
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).to include(nico_tag.name)
end
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
sign_in_as(member)
nico_tag = create_nico_tag!('nico:relation_source')
linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)
NicoTagRelation.create!(nico_tag:, tag: linked_tag)
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: post_record.title,
tags: "spec_tag #{ Tag.no_deerjikist.name }")
expect(response).to have_http_status(:ok)
names = post_record.reload.tags.map(&:name)
expect(names).to include(nico_tag.name)
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).not_to include(linked_tag.name)
end
it 'force-updates stale posts without base_version_no' do
sign_in_as(member)
create_post_version_for!(post_record.reload)
post_record.update!(title: 'updated by other user')
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
put "/posts/#{post_record.id}", params: post_write_params(
title: 'forced title',
tags: "spec_tag #{Tag.no_deerjikist.name}",
force: '1')
expect(response).to have_http_status(:ok)
expect(post_record.reload.title).to eq('forced title')
end
end
end
@@ -773,20 +1408,20 @@ RSpec.describe 'Posts API', type: :request do
post.snapshot_tag_names.join(' ')
end
def create_post_version!(post, version_no:, event_type:, created_by_user:, created_at:)
def create_post_version! post, version_no:, event_type:, created_by_user:, created_at:
PostVersion.create!(
post: post,
version_no: version_no,
event_type: event_type,
post:,
version_no:,
event_type:,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent: post.parent,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: created_at,
created_by_user: created_by_user
created_at:,
created_by_user:
)
end
@@ -1015,33 +1650,15 @@ 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),
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: {
post '/posts', params: post_write_params(
title: 'versioned post',
url: 'https://example.com/versioned-post',
tags: 'spec_tag',
thumbnail: dummy_upload
}
thumbnail: dummy_upload)
end.to change(PostVersion, :count).by(1)
expect(response).to have_http_status(:created)
@@ -1058,16 +1675,16 @@ RSpec.describe 'Posts API', type: :request do
it 'creates next version on PUT /posts/:id when snapshot changes' do
sign_in_as(member)
create_post_version_for!(post_record)
base_version = 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: {
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated title',
tags: 'spec_tag_2'
}
tags: 'spec_tag_2')
end.to change(PostVersion, :count).by(1)
expect(response).to have_http_status(:ok)
@@ -1084,14 +1701,15 @@ RSpec.describe 'Posts API', type: :request do
sign_in_as(member)
PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
create_post_version_for!(post_record.reload)
base_version = create_post_version_for!(post_record.reload)
expect {
put "/posts/#{post_record.id}", params: {
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: post_record.title,
tags: 'spec_tag'
}
tags: 'spec_tag')
}.not_to change(PostVersion, :count)
expect(response).to have_http_status(:ok)
version = post_record.reload.post_versions.order(:version_no).last
@@ -1104,12 +1722,11 @@ RSpec.describe 'Posts API', type: :request do
sign_in_as(member)
expect do
post '/posts', params: {
post '/posts', params: post_write_params(
title: 'invalid post',
url: 'ぼざクリタグ広場',
tags: 'spec_tag',
thumbnail: dummy_upload
}
thumbnail: dummy_upload)
end.not_to change(PostVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
@@ -1117,15 +1734,15 @@ RSpec.describe 'Posts API', type: :request do
it 'does not create a version when PUT /posts/:id is invalid' do
sign_in_as(member)
create_post_version_for!(post_record)
base_version = create_post_version_for!(post_record)
expect do
put "/posts/#{post_record.id}", params: {
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
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
}
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)
@@ -1135,48 +1752,22 @@ RSpec.describe 'Posts API', type: :request do
describe 'tag versioning from post write actions' do
let(:member) { create(:user, :member) }
it 'creates tag snapshot for normalised tags on POST /posts' do
sign_in_as(member)
expect {
post '/posts', params: {
title: 'tag versioned post',
url: 'https://example.com/tag-versioned-post',
tags: 'spec_tag',
thumbnail: dummy_upload
}
}.to change { tag.reload.tag_versions.count }.by(1)
expect(response).to have_http_status(:created)
version = tag.reload.tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('spec_tag')
expect(version.category).to eq('general')
expect(version.created_by_user_id).to eq(member.id)
end
it 'creates tag snapshot for normalised tags on PUT /posts/:id' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
tag_name2 = TagName.create!(name: 'spec_tag_2')
tag2 = Tag.create!(tag_name: tag_name2, category: :general)
expect {
put "/posts/#{post_record.id}", params: {
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated title',
tags: 'spec_tag_2'
}
tags: 'spec_tag_2')
}.to change { tag2.reload.tag_versions.count }.by(1)
expect(response).to have_http_status(:ok)
version = tag2.reload.tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('spec_tag_2')
expect(version.created_by_user_id).to eq(member.id)
expect(response).to have_http_status(:ok), response.body
end
end
end
+7 -2
View File
@@ -26,6 +26,7 @@ RSpec.describe 'TagVersions API', type: :request do
created_by_user:,
created_at:
)
version =
TagVersion.create!(
tag: tag,
version_no: version_no,
@@ -35,8 +36,12 @@ RSpec.describe 'TagVersions API', type: :request do
aliases: Array(aliases).join(' '),
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
created_by_user: created_by_user,
created_at: created_at
)
created_at: created_at)
tag.update_columns(version_no: version_no) if tag.has_attribute?(:version_no)
tag.version_no = version_no if tag.respond_to?(:version_no=)
version
end
let!(:v1) do
+222 -12
View File
@@ -8,23 +8,35 @@ RSpec.describe 'Tags deerjikists API', type: :request do
let!(:tag) { create(:tag, category: :deerjikist) }
let(:member) { create(:user, :member) }
let(:guest) { create(:user, role: :guest) }
before do
# show_by_name / deerjikists_by_name 用に名前を固定
tag.tag_name.update!(name: 'deerjika')
end
describe 'GET /tags/:id/deerjikists' do
subject(:do_request) do
get "/tags/#{ tag_id }/deerjikists"
get "/tags/#{tag_id}/deerjikists"
end
let(:tag_id) { tag.id }
context 'when tag exists and has no deerjikists' do
it 'returns 200 and empty array' do
it 'returns 200 with tag and empty deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to eq([])
expect(json).to be_a(Hash)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(false)
expect(json['deerjikists']).to eq([])
end
end
@@ -34,14 +46,24 @@ RSpec.describe 'Tags deerjikists API', type: :request do
Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end
it 'returns 200 and deerjikists array' do
it 'returns 200 with tag and deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to be_a(Array)
expect(json.size).to eq(2)
expect(json).to be_a(Hash)
expect(json.map { |h| [h['platform'], h['code']] }).to contain_exactly(
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(true)
expect(json['deerjikists']).to be_a(Array)
expect(json['deerjikists'].size).to eq(2)
expect(json['deerjikists'].map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
@@ -53,6 +75,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
it 'returns 404' do
do_request
expect(response).to have_http_status(:not_found)
end
end
@@ -60,7 +83,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
describe 'GET /tags/name/:name/deerjikists' do
subject(:do_request) do
get "/tags/name/#{ name }/deerjikists"
get "/tags/name/#{name}/deerjikists"
end
let(:name) { 'deerjika' }
@@ -70,6 +93,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
@@ -79,23 +103,209 @@ RSpec.describe 'Tags deerjikists API', type: :request do
it 'returns 404' do
do_request
expect(response).to have_http_status(:not_found)
end
end
context 'when tag exists and has no deerjikists' do
it 'returns 200 with tag and empty deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to be_a(Hash)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(false)
expect(json['deerjikists']).to eq([])
end
end
context 'when tag exists and has deerjikists' do
before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
it 'returns 200 and deerjikists array' do
it 'returns 200 with tag and deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to be_a(Hash)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(true)
expect(json['deerjikists']).to be_a(Array)
expect(json['deerjikists'].size).to eq(1)
expect(json['deerjikists'][0]['platform']).to eq(platform1)
expect(json['deerjikists'][0]['code']).to eq(code1)
end
end
end
describe 'PUT /tags/:id/deerjikists' do
subject(:do_request) do
put "/tags/#{tag_id}/deerjikists", params: payload, as: :json
end
let(:tag_id) { tag.id }
let(:payload) do
[
{ platform: platform1, code: code1 },
{ platform: platform2, code: code2 },
]
end
context 'when not logged in' do
it 'returns 401' do
do_request
expect(response).to have_http_status(:unauthorized)
end
end
context 'when logged in but not member' do
before do
sign_in_as guest
end
it 'returns 403' do
do_request
expect(response).to have_http_status(:forbidden)
end
end
context 'when tag does not exist' do
let(:tag_id) { 9_999_999 }
before do
sign_in_as member
end
it 'returns 404' do
do_request
expect(response).to have_http_status(:not_found)
end
end
context 'when logged in as member' do
before do
sign_in_as member
end
context 'when tag has no deerjikists' do
it 'creates deerjikists and returns deerjikists array' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(0).to(2)
expect(response).to have_http_status(:ok)
expect(json).to be_a(Array)
expect(json.map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
expect(Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end
context 'when tag already has deerjikists' do
before do
Deerjikist.create!(platform: platform1, code: 'old-code-1', tag: tag)
Deerjikist.create!(platform: platform2, code: 'old-code-2', tag: tag)
end
it 'replaces deerjikists and returns deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
expect(Deerjikist.exists?(platform: platform1, code: 'old-code-1')).to eq(false)
expect(Deerjikist.exists?(platform: platform2, code: 'old-code-2')).to eq(false)
expect(json).to be_a(Array)
expect(json.map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end
context 'when payload is empty array' do
let(:payload) { [] }
before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end
it 'clears deerjikists and returns empty array' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(2).to(0)
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end
context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do
[
{ platform: 'youtube', code: '@deerjika' },
]
end
before do
allow(Net::HTTP).to receive(:get).and_return(
%(<link rel="canonical" href="https://www.youtube.com/channel/#{channel_id}">),
)
end
it 'normalises youtube handle to channel id' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(0).to(1)
expect(response).to have_http_status(:ok)
expect(Net::HTTP).to have_received(:get)
expect(Deerjikist.exists?(platform: 'youtube', code: channel_id, tag: tag))
.to eq(true)
expect(json).to be_a(Array)
expect(json.size).to eq(1)
expect(json[0]['platform']).to eq(platform1)
expect(json[0]['code']).to eq(code1)
expect(json[0]['platform']).to eq('youtube')
expect(json[0]['code']).to eq(channel_id)
end
end
end
end
+217 -60
View File
@@ -1,109 +1,266 @@
require "rails_helper"
require 'rails_helper'
RSpec.describe 'Users', type: :request do
let(:remote_ip) { '203.0.113.10' }
before do
allow_any_instance_of(ActionDispatch::Request)
.to receive(:remote_ip)
.and_return(remote_ip)
end
def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code }
end
describe 'POST /users' do
it 'creates guest user, IpAddress and UserIp, and returns code' do
expect {
post '/users'
}.to change(User, :count).by(1)
.and change(IpAddress, :count).by(1)
.and change(UserIp, :count).by(1)
RSpec.describe "Users", type: :request do
describe "POST /users" do
it "creates guest user and returns code" do
post "/users"
expect(response).to have_http_status(:created)
expect(json["code"]).to be_present
expect(json["user"]["role"]).to eq("guest")
expect(json['code']).to be_present
expect(json['user']['role']).to eq('guest')
user = User.last
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
expect(user.role).to eq('guest')
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end
it 'returns 403 and does not create user when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
expect {
post '/users'
}.not_to change(User, :count)
expect(response).to have_http_status(:forbidden)
expect(UserIp.count).to eq(0)
end
end
describe "POST /users/code/renew" do
it "returns 401 when not logged in" do
sign_out
post "/users/code/renew"
expect(response).to have_http_status(:unauthorized)
end
end
describe 'POST /users/code/renew' do
it 'returns 401 when not logged in' do
post '/users/code/renew'
describe "PUT /users/:id" do
let(:user) { create(:user, name: "old-name", role: "guest") }
it "returns 401 when current_user id mismatch" do
sign_in_as(create(:user))
put "/users/#{user.id}", params: { name: "new-name" }
expect(response).to have_http_status(:unauthorized)
end
it "returns 400 when name is blank" do
sign_in_as(user)
put "/users/#{user.id}", params: { name: " " }
it 'returns 403 when current user is banned' do
user = create(:user, :banned)
post '/users/code/renew', headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
end
it 'returns 403 when current IP address is banned' do
user = create(:user)
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
post '/users/code/renew', headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
end
end
describe 'PUT /users/:id' do
let(:user) { create(:user, name: 'old-name', role: 'guest') }
it 'returns 401 when current_user id mismatch' do
other_user = create(:user)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(other_user)
expect(response).to have_http_status(:unauthorized)
end
it 'returns 400 when name is blank' do
put "/users/#{user.id}",
params: { name: ' ' },
headers: auth_headers(user)
expect(response).to have_http_status(:bad_request)
end
it "updates name and returns 201 with user slice" do
sign_in_as(user)
put "/users/#{user.id}", params: { name: "new-name" }
it 'updates name and returns user slice' do
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(json["id"]).to eq(user.id)
expect(json["name"]).to eq("new-name")
expect(json['id']).to eq(user.id)
expect(json['name']).to eq('new-name')
user.reload
expect(user.name).to eq("new-name")
expect(user.name).to eq('new-name')
end
it 'returns 403 when current user is banned' do
user.update!(banned_at: Time.current)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
user.reload
expect(user.name).to eq('old-name')
end
it 'returns 403 when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
user.reload
expect(user.name).to eq('old-name')
end
end
describe "POST /users/verify" do
it "returns valid:false when code not found" do
post "/users/verify", params: { code: "nope" }
describe 'POST /users/verify' do
it 'returns valid:false when code not found' do
post '/users/verify', params: { code: 'nope' }
expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(false)
expect(json['valid']).to eq(false)
end
it "creates IpAddress and UserIp, and returns valid:true with user slice" do
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
it 'returns 403 when current IP address is banned' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
# request.remote_ip を固定
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
expect {
post "/users/verify", params: { code: user.inheritance_code }
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:forbidden)
end
it 'returns 403 when verified user is banned' do
user = create(
:user,
:banned,
inheritance_code: SecureRandom.uuid,
role: 'guest'
)
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:forbidden)
end
it 'creates IpAddress and UserIp, and returns valid:true with user slice' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1)
.and change(IpAddress, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(true)
expect(json["user"]["id"]).to eq(user.id)
expect(json["user"]["inheritance_code"]).to eq(user.inheritance_code)
expect(json["user"]["role"]).to eq("guest")
expect(json['valid']).to eq(true)
expect(json['user']['id']).to eq(user.id)
expect(json['user']['inheritance_code']).to eq(user.inheritance_code)
expect(json['user']['role']).to eq('guest')
# ついでに IpAddress もできてることを確認(ipの保存形式がバイナリでも count で見れる)
expect(IpAddress.count).to be >= 1
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end
it "is idempotent for same user+ip (does not create duplicate UserIp)" do
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
it 'is idempotent for same user and same IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
post "/users/verify", params: { code: user.inheritance_code }
post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)
expect {
post "/users/verify", params: { code: user.inheritance_code }
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(true)
expect(json['valid']).to eq(true)
end
it 'creates another UserIp for same user and different IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)
allow_any_instance_of(ActionDispatch::Request)
.to receive(:remote_ip)
.and_return('203.0.113.11')
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(true)
end
end
describe "GET /users/me" do
it "returns 404 when code not found" do
get "/users/me", params: { code: "nope" }
describe 'GET /users/me' do
it 'returns 404 when code not found' do
get '/users/me', params: { code: 'nope' }
expect(response).to have_http_status(:not_found)
end
it "returns user slice when found" do
user = create(:user, inheritance_code: SecureRandom.uuid, name: "me", role: "guest")
get "/users/me", params: { code: user.inheritance_code }
it 'returns user slice when found' do
user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest')
get '/users/me', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)
expect(json["id"]).to eq(user.id)
expect(json["name"]).to eq("me")
expect(json["inheritance_code"]).to eq(user.inheritance_code)
expect(json["role"]).to eq("guest")
expect(json['id']).to eq(user.id)
expect(json['name']).to eq('me')
expect(json['inheritance_code']).to eq(user.inheritance_code)
expect(json['role']).to eq('guest')
end
it 'returns 403 when current IP address is banned' do
user = create(:user, inheritance_code: SecureRandom.uuid)
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
get '/users/me', params: { code: user.inheritance_code }
expect(response).to have_http_status(:forbidden)
end
end
end
@@ -0,0 +1,85 @@
require 'rails_helper'
RSpec.describe VersionRecorder do
let(:member) { create(:user, :member) }
let(:post_record) do
Post.create!(
title: 'version recorder post',
url: 'https://example.com/version-recorder-post')
end
it 'updates record version_no when creating the first version' do
version =
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
expect(version.version_no).to eq(1)
expect(post_record.reload.version_no).to eq(1)
end
it 'updates record version_no when creating the next version' do
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
post_record.update!(title: 'updated version recorder post')
version =
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(version.version_no).to eq(2)
expect(post_record.reload.version_no).to eq(2)
end
it 'does not create a new version or advance version_no when snapshot is unchanged' do
first =
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
expect {
version =
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(version).to eq(first)
}.not_to change(PostVersion, :count)
expect(post_record.reload.version_no).to eq(1)
end
it 'raises when record version_no is older than the latest version' do
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
post_record.update!(title: 'updated once')
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
post_record.update_columns(version_no: 1)
post_record.update!(title: 'updated with stale version_no')
expect {
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
}.to raise_error(RuntimeError, /version_no/)
end
end
+2 -4
View File
@@ -2,14 +2,12 @@ module TestRecords
def create_member_user!
User.create!(name: 'spec user',
inheritance_code: SecureRandom.hex(16),
role: 'member',
banned: false)
role: 'member')
end
def create_admin_user!
User.create!(name: 'spec admin',
inheritance_code: SecureRandom.hex(16),
role: 'admin',
banned: false)
role: 'admin')
end
end
+1 -1
View File
@@ -104,7 +104,7 @@ RSpec.describe "nico:sync" do
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent: post.parent,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,
+646
View File
@@ -0,0 +1,646 @@
# Codex handoff for BTRC Hub / タグ広場
This document transfers project-specific context from prior ChatGPT-assisted design and review work to Codex.
Use this file as project background.
Use `AGENTS.md`, `backend/AGENTS.md`, and `frontend/AGENTS.md` for concrete coding rules and verification commands.
## Project identity
BTRC Hub / タグ広場 is a collaborative knowledge base for collecting, tagging, explaining, and rediscovering Bocchi the Rock creature-related works.
It is not a generic SNS.
It is not a comment board.
It is not a service for rehosting external content.
It is primarily a structured link, tag, wiki, material, and viewing-party system.
Core domains:
1. Posts
2. Tags
3. Wiki pages
4. Materials
5. Theatre / watch-party features
The project is already publicly accessible and indexed by search engines, but it has not been broadly announced. Treat it as a small public production system, not a private prototype.
## Current stack
Backend:
- Ruby 3.2.2
- Rails 8.0.2 API
- MySQL 8
- Active Storage
- Cloudflare R2 / S3-compatible storage is expected for uploaded files
- RSpec
Frontend:
- React 19.1
- Vite 6.3
- TypeScript 5.8
- Axios
- TanStack Query
- Tailwind CSS
- Framer Motion
- shadcn-like local components
- react-markdown
- react-markdown-editor-lite
- remark-wiki-autolink
Batch / background-like tasks:
- Rake tasks
- Nico sync
- YouTube sync
- Similarity calculation tasks
## Repository working principle
Before editing, inspect the existing implementation.
Do not invent a new architecture when the current repo already has an established convention.
Keep changes scoped to the requested issue.
Prefer small, reviewable changes over broad rewrites.
Do not perform unrelated cleanup in the same patch.
When a task has design ambiguity, first produce a short investigation and recommended plan. Do not silently choose a risky design.
## User coding preferences
General:
- Prefer single quotes for strings unless interpolation, escaping, or framework convention makes double quotes better.
- Do not add production dependencies without explicit approval.
- Do not perform broad formatting churn.
- Do not convert unrelated files to a different style.
Ruby:
- Do not put a space before method-call parentheses.
- Do not use `%w`.
- Do not use `%i`.
- Keep Rails code idiomatic, but preserve the user's style where the repo already uses it.
TypeScript / Python:
- The user prefers GNU-style spacing before parentheses where syntactically valid.
- Preserve existing project formatting if a formatter or nearby code dictates otherwise.
## Current authentication model
The system does not use normal email/password authentication.
Users are authenticated by inheritance code.
Frontend:
- Stores the code in `localStorage.user_code`.
- Sends it as the `X-Transfer-Code` header.
Backend:
- Looks up `users.inheritance_code`.
- Sets `current_user`.
Roles:
- `guest`
- `member`
- `admin`
Important helper:
- `User#gte_member?` returns true for `member` and `admin`.
Never introduce a conventional login assumption unless the issue explicitly asks for it.
## BAN / abuse-control model
The backend currently enforces BAN at API level.
The relevant before_action order is conceptually:
1. Reject banned IP address.
2. Authenticate user if transfer code exists.
3. Reject banned user.
Entities:
- `users.banned_at`
- `ip_addresses.banned_at`
- `user_ips`
IP addresses are stored as binary values using `IPAddr#hton`.
Do not weaken BAN behavior.
Do not move BAN checks behind optional authentication.
Do not make preview, theatre, verify, user creation, or public-looking endpoints bypass BAN without an explicit design decision.
## Public-operation assumptions
Current practical operation:
- A few editor accounts exist.
- Meaningful editing is mostly done by the owner.
- Read access is already public.
- Search engines have indexed the site.
- Future editor applications are expected through Discord.
- Prospective editors are likely people known in the Bocchi creature community.
This means security and moderation issues matter even if traffic is still small.
## Core domain summary
### Posts
Posts are external URL-based link records.
Important properties:
- `url` is required and unique.
- URLs are normalized.
- Only HTTP / HTTPS are allowed.
- Posts can have thumbnails through Active Storage.
- `uploaded_user_id` may be NULL for synced or bot-created posts.
- `original_created_from` and `original_created_before` represent a time range for original content creation.
- When both original time bounds exist, `from < before` is required.
Parent/child posts:
- Current implementation uses `post_implications`.
- It is many-to-many.
- Do not assume `posts.parent_id`.
- Frontend/API clients must send `parent_post_ids`, even when empty.
- `parent_post_ids` is parsed as a space-separated ID string.
- Self-parenting is invalid.
- Missing parent IDs are invalid.
Versions:
- `post_versions` stores snapshots.
- `version_no` is a per-post sequence.
- Snapshot includes title, URL, thumbnail base, tags, parent post IDs, original time bounds, event type, and actor.
- Optimistic locking for posts is planned / important, but do not assume it is fully implemented unless the code proves it.
### Tags
Tags are central.
There is separation between tag names and tag entities:
- `tag_names`
- `tags`
Categories:
- `deerjikist`
- `meme`
- `character`
- `general`
- `material`
- `nico`
- `meta`
Alias model:
- `tag_names.canonical_id` expresses aliases.
- `canonical_id = NULL` means canonical name.
- `canonical_id != NULL` means alias.
- An alias must not point to another alias.
- A tag name that already has a tag or wiki page generally must not be aliasified.
Tag normalization:
- User-entered tags are normalized through existing backend logic.
- Known aliases are canonicalized.
- Parent tags are expanded recursively.
- `nico:` is normally rejected for manual entry.
- Special tags such as tag-request / bot / unknown-deerjikist / video / niconico / youtube must be protected.
Do not casually change tag normalization, alias resolution, or parent expansion. These affect search, wiki, sync, and historical data.
### Nico tags
Nico tags use the `nico` category and have separate versioning.
Important relation:
- `nico_tag_relations` maps external Nico tags to internal tags.
- `nico_tag_id` must be a Nico category tag.
- `tag_id` must not be Nico category.
Do not allow ordinary manual tag editing to create or corrupt Nico tags.
### Deerjikists
Deerjikists map external platform identities to internal `deerjikist` tags.
Known platforms include:
- `nico`
- `youtube`
YouTube handles may be normalized to `UC...` channel IDs.
Do not treat user-facing handles and canonical channel IDs as interchangeable without checking existing code.
### Wiki
Wiki pages are a major knowledge layer.
Important points:
- Wiki pages are tied to tag-like titles.
- Title handling, aliases, and canonical tag names matter.
- There is line-level storage / revision-oriented behavior in the current implementation.
- There has been design tension between wiki revisions and wiki versions.
- Wiki conflict detection using `base_revision_id` exists on the backend side.
- Frontend support for conflict detection must be verified before assuming it is complete.
Do not redesign Wiki storage casually.
Do not add a second competing history system.
Do not break existing wiki URLs.
### Materials
Materials connect files or reference URLs to `material` or `character` tags.
Important properties:
- A material has a `tag_id`.
- The tag must be `material` or `character`.
- A material requires either `url` or attached `file`.
- Active Storage is involved.
- Upload/security policy matters more than plain link posting.
Important unresolved/risky area:
- Material creation permissions have historically been risky because upload endpoints can be abused.
- Prefer `member` or higher for material creation unless the issue explicitly says otherwise.
### Theatre
Theatre is an experimental watch-party style feature.
Known pieces include:
- Display
- Presence
- Next post
- Comments
- Host-like control
Do not assume theatre has complete CRUD/admin support unless the code proves it.
Theatre may become expensive if next-item selection uses random DB ordering.
## Current high-risk areas
Treat these areas with extra care.
### Security
- Preview API SSRF protection.
- External iframe / embed CSP.
- Markdown link safety.
- BAN / IP BAN bypass.
- Transfer-code leakage.
- Guest write access.
- Upload endpoints.
- Admin-only tag operations.
- System tag mutation.
### Data integrity
- Tag alias canonicalization.
- Tag parent expansion.
- Post parent many-to-many relationships.
- Version tables.
- `version_no` synchronization.
- Schema drift from branch migration contamination.
- Wiki revision/version split.
- Material version recording.
### Frontend correctness
- React Hooks must not be called conditionally.
- Role guards are currently spread across components/pages.
- TanStack Query keys must not collide between ID/name or ID/title variants.
- URL path segments containing tag names or wiki titles must use `encodeURIComponent`.
- API response types may allow `null` users for bot or migration data.
- Tag autocomplete has had duplicated logic and stale state hazards.
### Performance
- Avoid unbounded `limit`.
- Avoid `order('RAND()')` for growing tables.
- Avoid loading full relations just to count.
- Avoid Ruby-side sorting/paging for large histories.
- Tag sidebar client-side aggregation can become expensive.
- Wiki full-text search needs deliberate indexing/design.
## Current priority order
Use this as the default priority unless an issue says otherwise.
### P0: Safety before broad announcement
1. Preview API SSRF hardening.
2. Material creation permission tightening.
3. System tag mutation holes.
4. `GET /users/me` transfer-code leakage through query params.
5. Limit caps for index/history/comment APIs.
6. CSP / iframe sandbox policy.
7. Confirm BAN enforcement remains global.
### P1: Core correctness
1. Post optimistic locking with `version_no`.
2. Wiki edit conflict handling.
3. Wiki history/revision model clarification.
4. Wiki search truthfulness: implement body search or remove false UI.
5. Tag alias/canonical/wiki interaction.
6. Tag URL encoding.
7. TanStack Query key separation.
8. Frontend null-user handling.
9. React Hooks rule fixes.
10. Material version policy.
### P2: Operational/admin usability
1. Admin screens for users, IPs, bans, aliases, and settings.
2. Settings table and user settings usage.
3. Better tag sidebar.
4. Better role guard helpers.
5. Better frontend tests.
6. Better issue triage and closure of already-implemented issues.
### P3: Future features
1. Theatre list/create/edit/admin flow.
2. Muted/hidden tags.
3. Tag category custom colors.
4. Responsive refinements.
5. Watch-party improvements.
6. Broader embed support.
## Known issue triage notes
Some existing issues may already be partially or mostly implemented.
Before implementing an issue, check code first.
Examples:
- Tag search and OR/NOT search may already be mostly implemented.
- BAN enforcement may have been implemented after earlier issue drafts.
- YouTube sync exists and should not be treated as purely planned.
- Parent posts are many-to-many in current schema, even if older issues mention one-to-many.
- Some issues may reflect old schema or old branch state.
When in doubt:
1. Inspect current code.
2. Inspect schema.
3. Inspect routes.
4. Inspect frontend usage.
5. Report whether the issue is implemented, partially implemented, not implemented, or obsolete.
6. Only then edit.
## Verification expectations
Backend changes:
- Run RSpec when possible.
- Add request specs for API behavior changes.
- Add model specs for validation / normalization changes.
- Check migrations and schema consistency.
- Do not silently ignore pending migrations.
Frontend changes:
- Run build.
- Run lint if configured.
- Run tests if configured.
- Add tests for important behavior when the test framework exists.
- If frontend tests are not yet installed, state that clearly.
Full-stack changes:
- Verify both backend and frontend compile/test paths where possible.
- Confirm API response shapes match TypeScript types.
- Confirm authorization behavior on both server and UI.
If commands cannot be run because dependencies are missing, report that explicitly. Do not pretend verification passed.
## Branch / migration caution
The project has previously suffered from schema contamination caused by running migrations from another branch.
Be careful when touching:
- `db/schema.rb`
- migration files
- parent post schema
- banned / banned_at schema
- version_no migrations
- wiki asset schema
Before changing migrations:
1. Inspect current schema.
2. Inspect existing migrations.
3. Confirm whether the intended branch already includes related migrations.
4. Prefer additive migrations for shared branches.
5. Do not edit already-applied production migrations unless explicitly instructed.
## API design principles
Prefer explicit server-side authorization.
Do not rely only on frontend hiding.
Do not return sensitive codes unnecessarily.
Use 403 for authorization failures.
Use 422 for validation failures.
Use 409 for edit conflicts.
Do not expose internal exception messages to users.
Clamp or reject abusive limits consistently.
Keep response shape stable unless the issue explicitly includes a breaking API change.
## Frontend design principles
Use existing route and query-key conventions.
Use TanStack Query `enabled` rather than conditional hook calls.
Do not let role-based early returns change hook order.
Centralize repeated tag autocomplete logic when touching it.
Use `encodeURIComponent` for tag names and wiki titles in URL path segments.
Prefer graceful fallback for nullable actors:
- bot operation
- deleted user
- migration-created data
- external sync
Do not assume all API user fields are non-null.
## Testing priorities to add over time
Frontend tests are especially important because the backend already has more mature RSpec coverage.
Suggested first frontend tests:
1. Tag autocomplete.
2. Post form tag editing.
3. Tag URL encoding.
4. Wiki edit conflict UI.
5. Role guard behavior.
6. Null-user history rendering.
7. Dialog behavior.
8. Top navigation responsive behavior.
Backend test priorities:
1. BAN enforcement across public-looking endpoints.
2. Material permissions.
3. Preview SSRF rejection.
4. System tag protection.
5. Post optimistic locking.
6. Wiki conflict detection.
7. Tag alias/canonical behavior.
8. Limit caps.
9. Parent post parsing.
10. Version recorder behavior.
## What Codex should not do without explicit approval
Do not:
- Replace Rails.
- Replace React.
- Replace TanStack Query.
- Redesign the database.
- Rewrite Wiki storage.
- Remove version tables.
- Change authentication model.
- Change role names.
- Change tag category names.
- Add background job infrastructure.
- Add a new UI framework.
- Add a new test framework if one already exists.
- Add major dependencies.
- Change public URL design.
- Change production storage configuration.
- Remove historical data behavior.
- Simplify BAN/security checks.
- Treat the site as private-only.
## Good first Codex tasks
Start with investigation-only tasks.
Example:
```txt
Inspect the repository and summarize the Rails, React, TypeScript, and test setup.
Do not modify files.
List commands that actually exist in this repository.
List risks Codex should know before editing.
```
Then small safe patches:
```txt
Fix a React Hooks rule violation in one file.
Keep behavior unchanged.
Run the relevant frontend verification commands.
```
```txt
Add encodeURIComponent around one tag-name URL path segment.
Add or update a test if the project has a frontend test setup.
Run build/lint.
```
```txt
Add a request spec for a known authorization rule.
Do not change implementation unless the spec fails for the expected reason.
```
Avoid starting with:
- Wiki history redesign.
- Post versioning redesign.
- Full admin screen suite.
- Broad frontend refactor.
- Database cleanup.
- Authentication rewrite.
## Relationship with ChatGPT
ChatGPT has been used for:
- Design review.
- Risk analysis.
- Prioritization.
- Specification reconstruction.
- Migration/locking discussions.
- Codex migration planning.
Codex should be used mainly for:
- Repository inspection.
- Localized implementation.
- Test addition.
- Running verification commands.
- Producing small reviewable diffs.
For ambiguous architecture, Codex should stop and present options rather than implement a guessed design.
## Current strategic stance
The project should not be rewritten from scratch.
The current Rails + React system is acceptable.
The immediate goal is not elegance.
The immediate goal is safe public operation, data integrity, and maintainable incremental improvement.
Priority is:
1. Prevent abuse/security incidents.
2. Preserve data correctness.
3. Make editing safe for multiple users.
4. Add tests around fragile frontend behavior.
5. Improve admin/operation workflows.
6. Optimize performance after obvious dangerous patterns are removed.
## Final rule
When current code, old specs, issue drafts, and memory disagree, current code wins.
When current code is unsafe, write that explicitly and propose a small safe fix.
When the task is too broad, split it.
When verification cannot be performed, say exactly what was not verified.
+30
View File
@@ -0,0 +1,30 @@
# Commands
## Backend
```sh
cd backend
bundle install
bundle exec rails db:migrate
bundle exec rspec
bundle exec rails routes
```
## Frontend
```sh
cd frontend
npm install
npm run dev
npm run build
npm run lint
npm run test
npm run test:run
```
### Full verification
```sh
cd backend && bundle exec rspec
cd ../frontend && npm run test:run && npm run build && npm run lint
```
+80
View File
@@ -0,0 +1,80 @@
# Issue workflow
## Source of truth
Gitea Issues are the source of truth for tasks, discussions, labels, milestones, and status.
Do not copy the full backlog into git.
Repository documents may define:
- issue templates
- triage rules
- Codex task format
- verification rules
- release checklist
## Labels
Recommended labels:
- `P0`
- `P1`
- `P2`
- `P3`
- `security`
- `data-integrity`
- `backend`
- `frontend`
- `wiki`
- `tags`
- `materials`
- `theatre`
- `codex-ready`
- `needs-design`
- `blocked`
- `good-first-codex-task`
## Codex-ready criteria
An issue can be labeled `codex-ready` only when it has:
- clear background
- target area
- concrete tasks
- acceptance criteria
- verification commands
- explicit non-goals
- no unresolved architecture decision
## Workflow
1. Create or refine the issue in Gitea.
2. Add labels and milestone.
3. If design is unclear, label `needs-design`.
4. Discuss design before implementation.
5. When scoped enough, label `codex-ready`.
6. Give Codex the issue URL or copied issue body.
7. Codex creates a branch.
8. Codex implements a small patch.
9. Codex runs verification commands.
10. Human reviews the diff.
11. Merge.
12. Close the issue from the PR/commit message.
## Commit message
Use issue references when possible:
```txt
fix: prevent preview SSRF
Refs: #123
```
or
```
fix: prevent preview SSRF
Closes: #123
```
depending on whether the change fully resolves the issue.
+8
View File
@@ -0,0 +1,8 @@
# Release checklist
- [ ] Backend specs pass
- [ ] Frontend build passes
- [ ] No pending migrations
- [ ] Preview API SSRF checked
- [ ] BAN behavior checked
- [ ] CSP checked
+8
View File
@@ -0,0 +1,8 @@
# Roadmap
## Public announcement readiness
- Harden preview API
- Tighten material creation permission
- Add admin MVP
- Improve frontend tests
+95
View File
@@ -0,0 +1,95 @@
# frontend/AGENTS.md
## Scope
These rules apply to work under `frontend/`.
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS, Framer Motion, Radix UI-style components, MDX, and Zustand.
## Commands
Use only scripts that exist in `package.json`:
```sh
npm run dev
npm run build
npm run lint
npm run preview
```
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs `node scripts/generate-sitemap.js`.
There is currently no `test` script in `package.json`. Do not run or report `npm test` unless a test script is added.
After frontend changes, run:
```sh
npm run build
npm run lint
```
If either command cannot be run or fails, report the exact command and failure.
## TypeScript
- TypeScript is strict. `tsconfig.app.json` enables `strict`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
- Keep types explicit at module boundaries, API helpers, and exported utilities.
- Use `import type` for type-only imports.
- Prefer existing shared types from `src/types.ts` before adding local duplicate types.
- Preserve the repository's existing spacing style in TypeScript, including GNU-style spacing before call parentheses where it is already used.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
## React
- Use function components.
- Existing page components commonly export an anonymous function satisfying `FC`; match nearby file style when editing.
- React hooks must be called unconditionally and at the top level of components or custom hooks.
- Keep page-level components under `src/pages`.
- Keep shared and feature components under `src/components`.
- Use `react-router-dom` route params and navigation patterns already present in `src/App.tsx`.
- Encode URL path-segment values with `encodeURIComponent`.
## TanStack Query
- Use `@tanstack/react-query` for server state.
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there instead of using ad hoc arrays in components.
- Fetch functions should live in domain helpers under `src/lib`, such as `posts.ts`, `tags.ts`, or `wiki.ts`.
- Use `useQueryClient().invalidateQueries` with the shared root keys when mutations affect cached lists or detail views.
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create additional clients in feature code.
## API calls
- Use `src/lib/api.ts` for HTTP calls.
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts non-blob responses to camelCase.
- Send Rails snake_case params and request body keys where the backend expects them.
- Do not bypass the API wrapper unless there is a specific reason, such as a third-party request outside the Rails API.
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
## Imports and aliases
- The `@` alias points to `frontend/src`.
- Prefer `@/...` imports for app code instead of long relative paths.
- Keep type imports separate with `import type`.
- Match existing import grouping: external packages, app modules, then type imports.
## Tailwind and UI
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- Reuse components from `src/components/common`, `src/components/layout`, and `src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
## Lint and build constraints
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, and `eslint-plugin-react-refresh`.
- The hooks rules are enforced; fix hook ordering instead of disabling the rule.
- `react-refresh/only-export-components` is enabled as a warning with `allowConstantExport`.
- Build failures from unused locals or unused parameters are TypeScript errors, not lint-only issues.
## Files to avoid in routine work
- Do not edit `dist/` output directly.
- Do not inspect or modify `node_modules/` unless explicitly needed.
- Keep generated build artifacts out of source changes unless the user asks for them.
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{ ignores: ['dist', 'tailwind.config.js'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
+1138 -39
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -8,6 +8,8 @@
"build": "tsc -b && vite build",
"postbuild": "node scripts/generate-sitemap.js",
"lint": "eslint .",
"test": "vitest",
"test:run": "vitest run",
"preview": "vite preview"
},
"dependencies": {
@@ -45,6 +47,10 @@
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/axios": "^0.14.4",
"@types/markdown-it": "^14.1.2",
"@types/mdx": "^2.0.13",
@@ -58,11 +64,13 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.13",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vitest": "^4.1.5"
},
"description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.",
"main": "eslint.config.js",
+11 -2
View File
@@ -8,8 +8,10 @@ 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'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialListPage from '@/pages/materials/MaterialListPage'
@@ -58,6 +60,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
@@ -90,7 +93,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
}
export default (() => {
const App: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
@@ -136,7 +139,9 @@ export default (() => {
return (
<>
<RouteBlockerOverlay/>
<BrowserRouter>
<DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
@@ -146,7 +151,11 @@ export default (() => {
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
<Toaster/>
</DialogueProvider>
</BrowserRouter>
</>)
}) satisfies FC
}
export default App
@@ -19,7 +19,7 @@ type Props = {
sp?: boolean }
export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: Props) => {
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => {
const dndId = `tag-node:${ pathKey }`
const downPosRef = useRef<{ x: number; y: number } | null> (null)
@@ -96,4 +96,6 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }:
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>
</div>)
}) satisfies FC<Props>
}
export default DraggableDroppableTagRow
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { describe, expect, it } from 'vitest'
import ErrorScreen from '@/components/ErrorScreen'
describe ('ErrorScreen', () => {
it.each ([
[403, '権限ないよ(笑)'],
[404, 'ページないよ(笑)'],
[500, '鯖でエラー出たって(嘲笑)'],
[503, '鯖死んでるよ(泣)'],
]) ('renders status %s', (status, message) => {
render (
<HelmetProvider>
<ErrorScreen status={status}/>
</HelmetProvider>,
)
expect (screen.getByText (String (status))).toBeInTheDocument ()
expect (screen.getByText (message)).toBeInTheDocument ()
expect (screen.getByAltText ('逃げたギター')).toBeInTheDocument ()
})
it ('throws for unsupported statuses', () => {
expect (() => render (
<HelmetProvider>
<ErrorScreen status={418}/>
</HelmetProvider>,
)).toThrow ()
})
})
+4 -2
View File
@@ -10,7 +10,7 @@ import type { FC } from 'react'
type Props = { status: number }
export default (({ status }: Props) => {
const ErrorScreen: FC<Props> = ({ status }) => {
const [message, rightMsg, leftMsg]: [string, string, string] = (() => {
switch (status)
{
@@ -58,4 +58,6 @@ export default (({ status }: Props) => {
<p className="mr-[-.5em]">{message}</p>
</div>
</MainArea>)
}) satisfies FC<Props>
}
export default ErrorScreen
+4 -2
View File
@@ -31,7 +31,7 @@ const setChildrenById = (
}))
export default (() => {
const MaterialSidebar: FC = () => {
const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
@@ -94,4 +94,6 @@ export default (() => {
{renderTags (tags)}
</ul>
</SidebarComponent>)
}) satisfies FC
}
export default MaterialSidebar
+4 -2
View File
@@ -1,9 +1,11 @@
import type { FC } from 'react'
export default (() => (
const MenuSeparator: FC = () => (
<>
<span className="hidden md:inline flex items-center px-2">|</span>
<hr className="block md:hidden w-full opacity-25
border-t border-black dark:border-white"/>
</>)) satisfies FC
</>)
export default MenuSeparator
@@ -0,0 +1,69 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PostEditForm from '@/components/PostEditForm'
import { buildPost, buildTag } from '@/test/factories'
const postsApi = vi.hoisted (() => ({
updatePost: vi.fn (),
}))
const api = vi.hoisted (() => ({
isApiError: vi.fn (() => false),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => ({
choice: vi.fn (),
}),
}))
describe ('PostEditForm', () => {
it ('submits edited post fields with the current base version', async () => {
const onSave = vi.fn ()
const post = buildPost ({
id: 8,
versionNo: 4,
title: 'old',
tags: [
buildTag ({ name: 'general-tag', category: 'general' }),
buildTag ({ id: 2, name: 'nico-tag', category: 'nico' }),
],
parentPosts: [buildPost ({ id: 2, title: 'parent' })],
})
postsApi.updatePost.mockResolvedValueOnce ({
...post,
versionNo: 5,
title: 'new',
tags: [buildTag ({ name: 'new-tag' })],
})
render (<PostEditForm post={post} onSave={onSave}/>)
const [title, parentIds] = screen.getAllByRole ('textbox')
fireEvent.change (title, { target: { value: 'new' } })
fireEvent.change (parentIds, { target: { value: '3 4' } })
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
await waitFor (() => {
expect (postsApi.updatePost).toHaveBeenCalledWith (
expect.objectContaining ({
id: 8,
title: 'new',
parentPostIds: '3 4',
tags: 'general-tag',
}),
{ baseVersionNo: 4 },
)
})
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
})
})
+102 -17
View File
@@ -3,10 +3,13 @@ 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 { apiPut } from '@/lib/api'
import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api'
import { updatePost } from '@/lib/posts'
import type { FC } from 'react'
import type { FC, FormEvent } from 'react'
import type { Post, Tag } from '@/types'
@@ -30,25 +33,87 @@ type Props = { post: Post
onSave: (newPost: Post) => void }
export default (({ post, onSave }: Props) => {
const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [disabled, setDisabled] = useState (false)
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (post.originalCreatedFrom)
const [title, setTitle] = useState (post.title)
const [parentPostIds, setParentPostIds] =
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title)
const handleSubmit = async () => {
const data = await apiPut<Post> (
`/posts/${ post.id }`,
{ title, tags, original_created_from: originalCreatedFrom,
original_created_before: originalCreatedBefore },
{ headers: { 'Content-Type': 'multipart/form-data' } })
const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => {
try
{
const data = await updatePost (...args)
onSave ({ ...post,
versionNo: data.versionNo,
title: data.title,
tags: data.tags,
parentPosts: data.parentPosts,
childPosts: data.childPosts,
siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' })
}
catch (e)
{
const response = isApiError<{ mergeable?: boolean }> (e) ? e.response : undefined
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
}
}
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()
setDisabled (true)
try
{
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
}
finally
{
setDisabled (false)
}
}
useEffect (() => {
@@ -56,30 +121,50 @@ export default (({ post, onSave }: Props) => {
}, [post])
return (
<div className="max-w-xl pt-2 space-y-4">
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
{/* タイトル */}
<div>
<Label></Label>
<input type="text"
<input
type="text"
disabled={disabled}
className="w-full border rounded p-2"
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>
</div>
{/* 親投稿 */}
<div>
<Label>稿</Label>
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/>
<PostFormTagsArea
disabled={disabled}
tags={tags}
setTags={setTags}/>
{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
disabled={disabled}
originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}/>
{/* 送信 */}
<Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
<Button
type="submit"
disabled={disabled}>
</Button>
</div>)
}) satisfies FC<Props>
</form>)
}
export default PostEditForm
@@ -0,0 +1,63 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostEmbed from '@/components/PostEmbed'
import { buildPost } from '@/test/factories'
const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
}))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
}))
vi.mock ('@/components/NicoViewer', () => ({
default: ({ id }: { id: string }) => <div>Nico:{id}</div>,
}))
vi.mock ('react-youtube', () => ({
default: ({ videoId }: { videoId: string }) => <div>YouTube:{videoId}</div>,
}))
describe ('PostEmbed', () => {
beforeEach (() => {
vi.clearAllMocks ()
})
it ('embeds nicovideo watch URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}/>)
expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
})
it ('embeds x/twitter status URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>)
expect (screen.getByRole ('link', { name: '@someone' })).toBeInTheDocument ()
})
it ('embeds youtube watch URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://www.youtube.com/watch?v=abc123' })}/>)
expect (screen.getByText ('YouTube:abc123')).toBeInTheDocument ()
})
it ('asks before framing unknown external pages', async () => {
dialogue.confirm.mockResolvedValueOnce (true)
render (
<PostEmbed
post={buildPost ({ url: 'https://example.com/page', title: 'external' })}/>,
)
fireEvent.click (screen.getByRole ('link', { name: '外部ページを表示' }))
await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalled ()
})
expect (await screen.findByTitle ('external')).toHaveAttribute (
'src',
'https://example.com/page',
)
})
})
+19 -10
View File
@@ -3,6 +3,7 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, RefObject } from 'react'
@@ -15,7 +16,10 @@ type Props = {
onMetadataChange?: (meta: NiconicoMetadata) => void }
export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) => {
const dialogue = useDialogue ()
const [framed, setFramed] = useState (false)
const url = new URL (post.url)
switch (url.hostname.split ('.').slice (-2).join ('.'))
@@ -41,7 +45,7 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
case 'twitter.com':
case 'x.com':
{
const mUserId = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/)
const mUserId = url.pathname.match (/(?<=\/)[^/]+?(?=\/|$|\?)/)
const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)
if (!(mUserId) || !(mStatusId))
break
@@ -69,8 +73,6 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
}
}
const [framed, setFramed] = useState (false)
return (
<>
{framed
@@ -82,15 +84,22 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
height={360}/>)
: (
<div>
<a href="#" onClick={e => {
<a href="#" onClick={async e => {
e.preventDefault ()
setFramed (confirm ('未確認の外部ページを表示します。\n'
+ '悪意のあるスクリプトが実行される可能性があります。\n'
+ '表示しますか?'))
return
setFramed (await dialogue.confirm ({
title: '未確認の外部ページを表示します',
description: (
<div>
<p></p>
<p>?</p>
</div>),
confirmText: '表示' }))
}}>
</a>
</div>)}
</>)
}) satisfies FC<Props>
}
export default PostEmbed
@@ -0,0 +1,34 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('PostFormTagsArea', () => {
it ('updates text and fetches autocomplete for the selected token', async () => {
const setTags = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
renderWithProviders (<PostFormTagsArea tags="虹" setTags={setTags}/>)
const textarea = screen.getByRole ('textbox')
fireEvent.focus (textarea)
fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } })
fireEvent.change (textarea, { target: { value: '虹夏' } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹', nico: '0' } },
)
})
expect (setTags).toHaveBeenCalledWith ('虹夏')
})
})
+7 -4
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 { FC, SyntheticEvent } from 'react'
import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react'
import type { Tag } from '@/types'
@@ -31,12 +31,12 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
`${ value.slice (0, start) }${ text }${ value.slice (end) }`
type Props = {
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
tags: string
setTags: (tags: string) => void }
export default (({ tags, setTags }: Props) => {
const PostFormTagsArea: FC<Props> = ({ tags, setTags, ...rest }) => {
const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
@@ -76,6 +76,7 @@ export default (({ tags, setTags }: Props) => {
<div className="relative w-full">
<Label></Label>
<TextArea
{...rest}
ref={ref}
value={tags}
onChange={ev => setTags (ev.target.value)}
@@ -96,4 +97,6 @@ export default (({ tags, setTags }: Props) => {
activeIndex={-1}
onSelect={handleTagSelect}/>)}
</div>)
}) satisfies FC<Props>
}
export default PostFormTagsArea
+44
View File
@@ -0,0 +1,44 @@
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostList from '@/components/PostList'
import { buildPost } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const prefetchers = vi.hoisted (() => ({
prefetchForURL: vi.fn (),
}))
vi.mock ('@/lib/prefetchers', () => prefetchers)
describe ('PostList', () => {
beforeEach (() => {
prefetchers.prefetchForURL.mockResolvedValue (undefined)
})
it ('renders post thumbnails as links to post details', () => {
renderWithProviders (
<PostList posts={[
buildPost ({ id: 1, title: 'First', thumbnail: 'first.jpg' }),
buildPost ({ id: 2, title: null, url: 'https://example.com/second' }),
]}/>,
)
expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute (
'href',
'/posts/1',
)
expect (
screen.getByRole ('link', { name: 'https://example.com/second' }),
).toHaveAttribute ('href', '/posts/2')
})
it ('calls the optional click handler', () => {
const onClick = vi.fn ()
renderWithProviders (<PostList posts={[buildPost ()]} onClick={onClick}/>)
fireEvent.click (screen.getByRole ('link', { name: 'テスト投稿' }))
expect (onClick).toHaveBeenCalledTimes (1)
})
})
+9 -4
View File
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
import type { FC, MouseEvent } from 'react'
@@ -13,7 +14,7 @@ type Props = { posts: Post[]
onClick?: (event: MouseEvent<HTMLElement>) => void }
export default (({ posts, onClick }: Props) => {
const PostList: FC<Props> = ({ posts, onClick }) => {
const location = useLocation ()
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
@@ -39,8 +40,10 @@ export default (({ posts, onClick }: Props) => {
<motion.div
ref={cardRef}
layoutId={layoutId}
className="w-full h-full overflow-hidden rounded-xl shadow
transform-gpu will-change-transform"
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
whileHover={{ scale: 1.02 }}
onLayoutAnimationStart={() => {
if (!(cardRef.current))
@@ -67,4 +70,6 @@ export default (({ posts, onClick }: Props) => {
</PrefetchLink>)
})}
</div>)
}) satisfies FC<Props>
}
export default PostList
@@ -0,0 +1,63 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
describe ('PostOriginalCreatedTimeField', () => {
it ('updates from and before values', () => {
const setFrom = vi.fn ()
const setBefore = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom={null}
setOriginalCreatedFrom={setFrom}
originalCreatedBefore={null}
setOriginalCreatedBefore={setBefore}/>,
)
const inputs = screen.getAllByDisplayValue ('')
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
expect (setFrom).toHaveBeenCalledWith (expect.any (String))
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
})
it ('infers an exclusive before value on blur', () => {
const setBefore = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom={null}
setOriginalCreatedFrom={vi.fn ()}
originalCreatedBefore={null}
setOriginalCreatedBefore={setBefore}/>,
)
const input = screen.getAllByDisplayValue ('')[0]
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } })
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
})
it ('resets both values', () => {
const setFrom = vi.fn ()
const setBefore = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom="2026-01-01T00:00:00Z"
setOriginalCreatedFrom={setFrom}
originalCreatedBefore="2026-01-02T00:00:00Z"
setOriginalCreatedBefore={setBefore}/>,
)
const buttons = screen.getAllByRole ('button', { name: 'リセット' })
fireEvent.click (buttons[0])
fireEvent.click (buttons[1])
expect (setFrom).toHaveBeenCalledWith (null)
expect (setBefore).toHaveBeenCalledWith (null)
})
})
@@ -5,22 +5,25 @@ 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 (({ originalCreatedFrom,
const PostOriginalCreatedTimeField: FC<Props> = ({ disabled,
originalCreatedFrom,
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore }: Props) => (
setOriginalCreatedBefore }) => (
<div>
<Label></Label>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
@@ -40,6 +43,7 @@ export default (({ originalCreatedFrom,
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
@@ -51,6 +55,7 @@ export default (({ originalCreatedFrom,
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
@@ -58,6 +63,7 @@ export default (({ originalCreatedFrom,
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
@@ -65,4 +71,6 @@ export default (({ originalCreatedFrom,
</Button>
</div>
</div>
</div>)) satisfies FC<Props>
</div>)
export default PostOriginalCreatedTimeField
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import RouteBlockerOverlay, { useOverlayStore } from '@/components/RouteBlockerOverlay'
describe ('RouteBlockerOverlay', () => {
afterEach (() => {
useOverlayStore.setState ({ active: false })
document.body.style.overflow = ''
document.body.removeAttribute ('aria-busy')
})
it ('renders nothing while inactive', () => {
useOverlayStore.setState ({ active: false })
const { container } = render (<RouteBlockerOverlay/>)
expect (container).toBeEmptyDOMElement ()
})
it ('renders a blocking progressbar and marks the body busy while active', () => {
useOverlayStore.setState ({ active: true })
render (<RouteBlockerOverlay/>)
expect (screen.getByRole ('progressbar', { name: 'Loading' })).toBeInTheDocument ()
expect (document.body).toHaveAttribute ('aria-busy', 'true')
expect (document.body.style.overflow).toBe ('hidden')
})
})
@@ -13,7 +13,7 @@ export const useOverlayStore = create<OverlayStore> (set => ({
setActive: v => set ({ active: v }) }))
export default (() => {
const RouteBlockerOverlay: FC = () => {
const active = useOverlayStore (s => s.active)
useEffect (() => {
@@ -43,4 +43,6 @@ export default (() => {
</div>
</div>
</div>)
}) satisfies FC
}
export default RouteBlockerOverlay
@@ -0,0 +1,39 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import SortHeader from '@/components/SortHeader'
import { renderWithProviders } from '@/test/render'
describe ('SortHeader', () => {
it ('toggles the active sort direction and resets the page', () => {
renderWithProviders (
<SortHeader
by="title"
label="タイトル"
currentOrder="title:asc"
defaultDirection={{ title: 'asc' }}/>,
{ route: '/posts?tags=x&page=4&order=title%3Aasc' },
)
expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute (
'href',
'/posts?tags=x&page=1&order=title%3Adesc',
)
})
it ('uses default direction for inactive fields', () => {
renderWithProviders (
<SortHeader
by="updated_at"
label="更新"
currentOrder="title:desc"
defaultDirection={{ title: 'asc', updated_at: 'desc' }}/>,
{ route: '/posts?page=2' },
)
expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute (
'href',
'/posts?page=1&order=updated_at%3Adesc',
)
})
})
+4 -2
View File
@@ -151,7 +151,7 @@ const DropSlot = ({ cat }: { cat: Category }) => {
type Props = { post: Post; sp?: boolean }
export default (({ post, sp }: Props) => {
const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
sp = Boolean (sp)
const qc = useQueryClient ()
@@ -376,4 +376,6 @@ export default (({ post, sp }: Props) => {
</DragOverlay>
</DndContext>
</SidebarComponent>)
}) satisfies FC<Props>
}
export default TagDetailSidebar
+45
View File
@@ -0,0 +1,45 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import TagLink from '@/components/TagLink'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
describe ('TagLink', () => {
it ('links tag names to post search and shows counts', () => {
renderWithProviders (
<TagLink tag={buildTag ({ name: '虹 夏', postCount: 4 })}/>,
)
expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute (
'href',
'/posts?tags=%E8%99%B9+%E5%A4%8F',
)
expect (screen.getByText ('4')).toBeInTheDocument ()
})
it ('links wiki markers to the correct detail route', () => {
renderWithProviders (
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
)
expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute (
'href',
'/wiki/a%2Fb',
)
})
it ('renders aliases and non-link tags when requested', () => {
renderWithProviders (
<TagLink
tag={buildTag ({ matchedAlias: '別名', name: '正式名' })}
linkFlg={false}
withWiki={false}
withCount={false}/>,
)
expect (screen.getByText ('別名')).toBeInTheDocument ()
expect (screen.getByText ('正式名')).toBeInTheDocument ()
expect (screen.queryByRole ('link')).not.toBeInTheDocument ()
})
})
+27 -7
View File
@@ -27,12 +27,12 @@ type Props =
| PropsWithoutLink
export default (({ tag,
const TagLink: FC<Props> = ({ tag,
nestLevel = 0,
linkFlg = true,
withWiki = true,
withCount = true,
...props }: Props) => {
...props }) => {
const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
@@ -45,9 +45,9 @@ export default (({ tag,
<>
{(linkFlg && withWiki) && (
<span className="mr-1">
{(tag.materialId != null || tag.hasWiki)
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? (
tag.materialId == null
tag.materialId == null && !(tag.hasDeerjikists)
? (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
@@ -55,11 +55,19 @@ export default (({ tag,
?
</PrefetchLink>)
: (
tag.materialId != null
? (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
?
</PrefetchLink>))
</PrefetchLink>)
: (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className={linkClass}>
?
</PrefetchLink>)))
: (
['character', 'material'].includes (tag.category)
? (
@@ -70,6 +78,16 @@ export default (({ tag,
title={`${ tag.name } 素材情報が存在しません.`}>
!
</PrefetchLink>)
: (
tag.category === 'deerjikist'
? (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } に関する情報が存在しません.`}>
!
</PrefetchLink>)
: (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
@@ -77,7 +95,7 @@ export default (({ tag,
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>))}
</PrefetchLink>)))}
</span>)}
{nestLevel > 0 && (
<span
@@ -108,4 +126,6 @@ export default (({ tag,
{withCount && (
<span className="ml-1">{tag.postCount}</span>)}
</>)
}) satisfies FC<Props>
}
export default TagLink
+4 -2
View File
@@ -12,7 +12,7 @@ import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import type { Tag } from '@/types'
export default (() => {
const TagSearch: FC = () => {
const location = useLocation ()
const navigate = useNavigate ()
@@ -115,4 +115,6 @@ export default (() => {
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
}) satisfies FC
}
export default TagSearch
@@ -0,0 +1,30 @@
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import TagSearchBox from '@/components/TagSearchBox'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
describe ('TagSearchBox', () => {
it ('renders suggestions and selects tags on mouse down', () => {
const handleSelect = vi.fn ()
const tag = buildTag ({ id: 9, name: '候補', postCount: 2 })
renderWithProviders (
<TagSearchBox suggestions={[tag]} activeIndex={0} onSelect={handleSelect}/>,
)
fireEvent.mouseDown (screen.getByText ('候補'))
expect (handleSelect).toHaveBeenCalledWith (tag)
expect (screen.getByText ('2')).toBeInTheDocument ()
})
it ('renders nothing when suggestions are empty', () => {
const { container } = renderWithProviders (
<TagSearchBox suggestions={[]} activeIndex={-1} onSelect={vi.fn ()}/>,
)
expect (container).toBeEmptyDOMElement ()
})
})
+4 -2
View File
@@ -10,7 +10,7 @@ type Props = { suggestions: Tag[]
onSelect: (tag: Tag) => void }
export default (({ suggestions, activeIndex, onSelect }: Props) => {
const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
if (suggestions.length === 0)
return
@@ -26,4 +26,6 @@ export default (({ suggestions, activeIndex, onSelect }: Props) => {
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
</li>))}
</ul>)
}) satisfies FC<Props>
}
export default TagSearchBox
+4 -2
View File
@@ -19,7 +19,7 @@ type Props = { posts: Post[]
onClick?: (event: MouseEvent<HTMLElement>) => void }
export default (({ posts, onClick }: Props) => {
const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const navigate = useNavigate ()
const [tagsVsbl, setTagsVsbl] = useState (false)
@@ -126,4 +126,6 @@ export default (({ posts, onClick }: Props) => {
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
</a>
</SidebarComponent>)
}) satisfies FC<Props>
}
export default TagSidebar
+10 -8
View File
@@ -26,7 +26,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
pathName: string }): Menu => {
const postCount = tag?.postCount ?? 0
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId)
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? ''
const tagFlg = /^\/tags\/\d+/.test (pathName)
@@ -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' },
@@ -80,7 +80,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
}
export default (({ user }: Props) => {
const TopNav: FC<Props> = ({ user }) => {
const location = useLocation ()
const dirRef = useRef<(-1) | 1> (1)
@@ -159,12 +159,12 @@ export default (({ user }: Props) => {
useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId)
return () => unsubscribe ()
}, [activeIdx])
}, [])
useEffect (() => {
setMenuOpen (false)
setOpenItemIdx (activeIdx)
}, [location])
}, [activeIdx, location])
return (
<>
@@ -433,4 +433,6 @@ export default (({ user }: Props) => {
</motion.div>)}
</AnimatePresence>
</>)
}) satisfies FC<Props>
}
export default TopNav
@@ -0,0 +1,29 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import TopNavUser from '@/components/TopNavUser'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
describe ('TopNavUser', () => {
it ('renders nothing without a user', () => {
const { container } = renderWithProviders (<TopNavUser user={null}/>)
expect (container).toBeEmptyDOMElement ()
})
it ('links named users to settings', () => {
renderWithProviders (<TopNavUser user={buildUser ({ name: '山田' })}/>)
expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute (
'href',
'/users/settings',
)
})
it ('uses the anonymous display name', () => {
renderWithProviders (<TopNavUser user={buildUser ({ name: null })}/>)
expect (screen.getByRole ('link', { name: '名もなきニジラー' })).toBeInTheDocument ()
})
})
+4 -2
View File
@@ -10,7 +10,7 @@ type Props = { user: User | null,
sp?: boolean }
export default (({ user, sp }: Props) => {
const TopNavUser: FC<Props> = ({ user, sp }) => {
if (!(user))
return
@@ -28,4 +28,6 @@ export default (({ user, sp }: Props) => {
{user.name || '名もなきニジラー'}
</PrefetchLink>
</>)
}) satisfies FC<Props>
}
export default TopNavUser
@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import TwitterEmbed from '@/components/TwitterEmbed'
describe ('TwitterEmbed', () => {
it ('renders tweet and user links', () => {
render (<TwitterEmbed userId="user_name" statusId="12345"/>)
expect (screen.getByRole ('link', { name: '@user_name' })).toHaveAttribute (
'href',
'https://twitter.com/user_name?ref_src=twsrc%3Etfw',
)
expect (screen.getByRole ('link', { name: /\d/ })).toHaveAttribute (
'href',
'https://twitter.com/user_name/status/12345?ref_src=twsrc%5Etfw',
)
})
})
+4 -2
View File
@@ -5,7 +5,7 @@ type Props = {
statusId: string }
export default (({ userId, statusId }: Props) => {
const TwitterEmbed: FC<Props> = ({ userId, statusId }) => {
const now = (new Date).toLocaleDateString ()
return (
@@ -18,4 +18,6 @@ export default (({ userId, statusId }: Props) => {
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charSet="utf-8"/>
</div>)
}) satisfies FC<Props>
}
export default TwitterEmbed
+4 -2
View File
@@ -25,7 +25,7 @@ const mdComponents = { a: (({ href, children }) => (
</a>))) } as const satisfies Components
export default (({ title, body }: Props) => {
const WikiBody: FC<Props> = ({ title, body }) => {
const { data } = useQuery ({
enabled: Boolean (body),
queryKey: wikiKeys.index ({ }),
@@ -39,4 +39,6 @@ export default (({ title, body }: Props) => {
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
</ReactMarkdown>)
}) satisfies FC<Props>
}
export default WikiBody
@@ -0,0 +1,27 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DateTimeField from '@/components/common/DateTimeField'
describe ('DateTimeField', () => {
it ('renders an ISO value as a datetime-local value', () => {
render (<DateTimeField aria-label="日時" value="2026-01-02T03:04:05.000Z"/>)
const input = screen.getByLabelText ('日時')
expect (input).toHaveValue ('2026-01-02T12:04')
})
it ('reports local changes as ISO strings and empty values as null', () => {
const handleChange = vi.fn ()
render (<DateTimeField aria-label="日時" onChange={handleChange}/>)
const input = screen.getByLabelText ('日時')
fireEvent.change (input, { target: { value: '2026-01-02T03:04' } })
fireEvent.change (input, { target: { value: '' } })
const first = handleChange.mock.calls[0]?.[0]
expect (new Date (first).getFullYear ()).toBe (2026)
expect (handleChange).toHaveBeenLastCalledWith (null)
})
})
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import type { FC, FocusEvent } from 'react'
import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
const pad = (n: number): string => n.toString ().padStart (2, '0')
@@ -18,14 +18,14 @@ const toDateTimeLocalValue = (d: Date) => {
}
type Props = {
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
value?: string
onChange?: (isoUTC: string | null) => void
className?: string
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void }
export default (({ value, onChange, className, onBlur }: Props) => {
const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, ...rest }) => {
const [local, setLocal] = useState ('')
useEffect (() => {
@@ -34,6 +34,7 @@ export default (({ value, onChange, className, onBlur }: Props) => {
return (
<input
{...rest}
className={cn ('border rounded p-2', className)}
type="datetime-local"
value={local}
@@ -43,4 +44,6 @@ export default (({ value, onChange, className, onBlur }: Props) => {
onChange?.(v ? (new Date (v)).toISOString () : null)
}}
onBlur={onBlur}/>)
}) satisfies FC<Props>
}
export default DateTimeField
+4 -2
View File
@@ -3,7 +3,9 @@ import type { FC, ReactNode } from 'react'
type Props = { children: ReactNode }
export default (({ children }: Props) => (
const Form: FC<Props> = ({ children }) => (
<div className="max-w-xl mx-auto p-4 space-y-4">
{children}
</div>)) satisfies FC<Props>
</div>)
export default Form
@@ -0,0 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Label from '@/components/common/Label'
describe ('Label', () => {
it ('renders a plain label', () => {
render (<Label></Label>)
expect (screen.getByText ('名前')).toBeInTheDocument ()
})
it ('renders and toggles the optional checkbox', () => {
const handleChange = vi.fn ()
render (
<Label checkBox={{ label: '不明', checked: false, onChange: handleChange }}>
</Label>,
)
fireEvent.click (screen.getByRole ('checkbox', { name: '不明' }))
expect (handleChange).toHaveBeenCalledTimes (1)
})
})
+5 -1
View File
@@ -1,12 +1,14 @@
import React from 'react'
import type { FC } from 'react'
type Props = { children: React.ReactNode
checkBox?: { label: string
checked: boolean
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void } }
export default ({ children, checkBox }: Props) => {
const Label: FC<Props> = ({ children, checkBox }) => {
if (!(checkBox))
{
return (
@@ -26,3 +28,5 @@ export default ({ children, checkBox }: Props) => {
</label>
</div>)
}
export default Label
@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import PageTitle from '@/components/common/PageTitle'
describe ('PageTitle', () => {
it ('renders children as a level 1 heading', () => {
render (<PageTitle>Test title</PageTitle>)
const heading = screen.getByRole ('heading', { level: 1 })
expect (heading.textContent).toBe ('Test title')
})
})
+5 -1
View File
@@ -1,9 +1,13 @@
import React from 'react'
import type { FC } from 'react'
type Props = { children: React.ReactNode }
export default ({ children }: Props) => (
const PageTitle: FC<Props> = ({ children }) => (
<h1 className="text-2xl font-bold mb-2">
{children}
</h1>)
export default PageTitle
@@ -0,0 +1,38 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Pagination from '@/components/common/Pagination'
import { renderWithProviders } from '@/test/render'
describe ('Pagination', () => {
it ('builds page links while preserving existing query parameters', () => {
renderWithProviders (
<Pagination page={3} totalPages={5} siblingCount={1}/>,
{ route: '/posts?tags=abc&page=3' },
)
expect (screen.getByLabelText ('前のページ')).toHaveAttribute (
'href',
'/posts?tags=abc&page=2',
)
expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
'href',
'/posts?tags=abc&page=4',
)
expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page')
})
it ('does not render active previous and next controls at the edges', () => {
const { rerender } = renderWithProviders (
<Pagination page={1} totalPages={1}/>,
{ route: '/tags' },
)
expect (screen.queryByLabelText ('前のページ')).not.toBeInTheDocument ()
expect (screen.queryByLabelText ('次のページ')).not.toBeInTheDocument ()
rerender (<Pagination page={1} totalPages={2}/>)
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/tags?page=2')
})
})
@@ -48,7 +48,7 @@ const getPages = (
}
export default (({ page, totalPages, siblingCount = 3 }) => {
const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
const location = useLocation ()
const buildTo = (p: number) => {
@@ -124,4 +124,6 @@ export default (({ page, totalPages, siblingCount = 3 }) => {
</>)}
</div>
</nav>)
}) satisfies FC<Props>
}
export default Pagination
@@ -5,7 +5,9 @@ import type { ComponentPropsWithoutRef, FC } from 'react'
type Props = ComponentPropsWithoutRef<'h2'>
export default (({ children, className, ...rest }: Props) => (
const SectionTitle: FC<Props> = ({ children, className, ...rest }) => (
<h2 {...rest} className={cn ('text-xl my-4', className)}>
{children}
</h2>)) satisfies FC<Props>
</h2>)
export default SectionTitle
@@ -1,9 +1,13 @@
import React from 'react'
import type { FC } from 'react'
type Props = { children: React.ReactNode }
export default ({ children }: Props) => (
const SubsectionTitle: FC<Props> = ({ children }) => (
<h3 className="my-2">
{children}
</h3>)
export default SubsectionTitle
@@ -0,0 +1,23 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import TabGroup, { Tab } from '@/components/common/TabGroup'
describe ('TabGroup', () => {
it ('uses the init tab and switches tabs when clicked', () => {
render (
<TabGroup>
<Tab name="A">Alpha</Tab>
<Tab name="B" init>Beta</Tab>
</TabGroup>,
)
expect (screen.queryByText ('Alpha')).not.toBeInTheDocument ()
expect (screen.getByText ('Beta')).toBeInTheDocument ()
fireEvent.click (screen.getByText ('A'))
expect (screen.getByText ('Alpha')).toBeInTheDocument ()
expect (screen.queryByText ('Beta')).not.toBeInTheDocument ()
})
})
+5 -1
View File
@@ -1,3 +1,5 @@
import type { FC } from 'react'
import React, { useState } from 'react'
import { cn } from '@/lib/utils'
@@ -10,7 +12,7 @@ type Props = { children: React.ReactNode }
export const Tab = ({ children }: TabProps) => <>{children}</>
export default ({ children }: Props) => {
const TabGroup: FC<Props> = ({ children }) => {
const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[]
const [current, setCurrent] = useState<number> (() => {
@@ -37,3 +39,5 @@ export default ({ children }: Props) => {
</div>
</div>)
}
export default TabGroup
@@ -0,0 +1,44 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagInput from '@/components/common/TagInput'
import { buildTag } from '@/test/factories'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('TagInput', () => {
beforeEach (() => {
vi.clearAllMocks ()
})
it ('updates value and fetches autocomplete for the last token', async () => {
const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
render (<TagInput value="ぼっち 虹" setValue={setValue}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏' } },
)
})
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
})
it ('does not fetch when the last token is blank', () => {
const setValue = vi.fn ()
render (<TagInput value="" setValue={setValue}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: ' ' } })
expect (api.apiGet).not.toHaveBeenCalled ()
expect (setValue).toHaveBeenCalledWith (' ')
})
})
+8 -3
View File
@@ -12,7 +12,7 @@ type Props = {
value: string
setValue: (value: string) => void }
export default (({ value, setValue }: Props) => {
const TagInput: FC<Props> = ({ value, setValue }) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
@@ -62,9 +62,12 @@ export default (({ value, setValue }: Props) => {
case 'Enter':
if (activeIndex < 0)
break
{
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
if (selected)
handleTagSelect (selected)
}
break
case 'Escape':
@@ -94,4 +97,6 @@ export default (({ value, setValue }: Props) => {
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
}) satisfies FC<Props>
}
export default TagInput
@@ -0,0 +1,37 @@
import { createRef } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Form from '@/components/common/Form'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import TextArea from '@/components/common/TextArea'
describe ('common typography and form components', () => {
it ('renders Form children inside the standard container', () => {
render (<Form><span>Content</span></Form>)
expect (screen.getByText ('Content')).toBeInTheDocument ()
})
it ('renders SectionTitle as an h2', () => {
render (<SectionTitle>Section</SectionTitle>)
expect (screen.getByRole ('heading', { level: 2, name: 'Section' })).toBeInTheDocument ()
})
it ('renders SubsectionTitle as an h3', () => {
render (<SubsectionTitle>Subsection</SubsectionTitle>)
expect (screen.getByRole ('heading', { level: 3, name: 'Subsection' })).toBeInTheDocument ()
})
it ('forwards refs and props to TextArea', () => {
const ref = createRef<HTMLTextAreaElement> ()
render (<TextArea ref={ref} aria-label="Body" defaultValue="text"/>)
expect (ref.current).toBe (screen.getByLabelText ('Body'))
expect (screen.getByLabelText ('Body')).toHaveValue ('text')
})
})
@@ -0,0 +1,189 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
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 }
const DialogueProvider: FC<Props> = ({ children }) => {
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 className="px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle>{active.options.title}</DialogTitle>
{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<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>)
}
export const useDialogue = () => {
const dialogue = useContext (DialogueContext)
if (!(dialogue))
throw new Error ('useDialogue must be used inside DialogueProvider')
return dialogue
}
export default DialogueProvider
+4 -2
View File
@@ -9,10 +9,12 @@ type Props = {
className?: string }
export default (({ children, className }: Props) => (
const MainArea: FC<Props> = ({ children, className }) => (
<motion.main
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout="position">
{children}
</motion.main>)) satisfies FC<Props>
</motion.main>)
export default MainArea
@@ -6,7 +6,7 @@ import type { FC, ReactNode } from 'react'
type Props = { children: ReactNode }
export default (({ children }: Props) => (
const SidebarComponent: FC<Props> = ({ children }) => (
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
@@ -27,4 +27,6 @@ export default (({ children }: Props) => (
</Helmet>
{children}
</motion.div>)) satisfies FC<Props>
</motion.div>)
export default SidebarComponent
+29 -16
View File
@@ -4,34 +4,47 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
const buttonVariants = cva (
[
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
'rounded-md text-sm font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
].join (' '),
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default:
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800',
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
ghost:
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300',
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
})
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+15 -11
View File
@@ -37,25 +37,29 @@ const DialogContent = React.forwardRef<
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 w-[90%] grid max-w-lg',
className={cn (
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
'gap-4 border bg-gray-300/80 dark:bg-gray-700/80',
'p-6 shadow-lg duration-200',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'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="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
className={cn (
'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/>
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
+4 -13
View File
@@ -18,13 +18,6 @@ type ToasterToast = ToastProps & {
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
@@ -32,23 +25,21 @@ function genId() {
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
type: "ADD_TOAST"
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
type: "UPDATE_TOAST"
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
type: "DISMISS_TOAST"
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
type: "REMOVE_TOAST"
toastId?: ToasterToast["id"]
}

Some files were not shown because too many files have changed in this diff Show More