Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b50642756 | |||
| de86879e79 | |||
| 772c66aa64 | |||
| d54e66a114 |
@@ -1,35 +0,0 @@
|
|||||||
## 背景
|
|
||||||
|
|
||||||
なぜ必要か。
|
|
||||||
|
|
||||||
## 対象範囲
|
|
||||||
|
|
||||||
- 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 を読んで実装してください。
|
|
||||||
不明点があれば、実装前に調査結果と選択肢を提示してください。
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -177,8 +177,8 @@ class PostsController < ApplicationController
|
|||||||
merge = bool?(:merge)
|
merge = bool?(:merge)
|
||||||
return head :bad_request if force && merge
|
return head :bad_request if force && merge
|
||||||
|
|
||||||
base_version_no = parse_base_version_no
|
base_version_no = nil
|
||||||
return head :bad_request if !(force) && !(base_version_no)
|
base_version_no = parse_base_version_no unless force
|
||||||
|
|
||||||
title = params[:title].presence
|
title = params[:title].presence
|
||||||
tag_names = params[:tags].to_s.split
|
tag_names = params[:tags].to_s.split
|
||||||
@@ -442,11 +442,9 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
def parse_base_version_no
|
def parse_base_version_no
|
||||||
version_no = Integer(params[:base_version_no], exception: false)
|
version_no = Integer(params[:base_version_no], exception: false)
|
||||||
if version_no&.positive?
|
raise ArgumentError, 'base_version_no は必須です.' unless version_no&.positive?
|
||||||
version_no
|
|
||||||
else
|
version_no
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_snapshot_from_version version
|
def post_snapshot_from_version version
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class VersionRecorder
|
|||||||
@record = record_class.unscoped.lock.find(@record.id)
|
@record = record_class.unscoped.lock.find(@record.id)
|
||||||
latest = latest_version
|
latest = latest_version
|
||||||
|
|
||||||
validate_version_sequence!(latest)
|
validate_version_sequence! latest
|
||||||
|
|
||||||
attrs = snapshot_attributes
|
attrs = snapshot_attributes
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ class VersionRecorder
|
|||||||
version = version_class.create!(
|
version = version_class.create!(
|
||||||
base_attributes(latest).merge(record_key => @record).merge(attrs))
|
base_attributes(latest).merge(record_key => @record).merge(attrs))
|
||||||
|
|
||||||
update_record_version_no!(version.version_no)
|
update_record_version_no! version.version_no
|
||||||
|
|
||||||
version
|
version
|
||||||
end
|
end
|
||||||
@@ -47,7 +47,7 @@ class VersionRecorder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update_record_version_no! version_no
|
def update_record_version_no! version_no
|
||||||
@record.update_columns(version_no:)
|
@record.update_columns version_no: version_no
|
||||||
@record.version_no = version_no
|
@record.version_no = version_no
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
|
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_nico_tag!(name)
|
|
||||||
Tag.find_or_create_by_tag_name!(name, category: :nico)
|
|
||||||
end
|
|
||||||
|
|
||||||
def dummy_upload
|
def dummy_upload
|
||||||
# 中身は何でもいい(加工処理はスタブしてる)
|
# 中身は何でもいい(加工処理はスタブしてる)
|
||||||
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
||||||
@@ -27,34 +23,21 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
Post.create!(title:, url:)
|
Post.create!(title:, url:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_post_version_for!(post)
|
def create_post_version_for! post
|
||||||
version =
|
PostVersion.create!(
|
||||||
PostVersion.create!(
|
post:,
|
||||||
post:,
|
version_no: 1,
|
||||||
version_no: 1,
|
event_type: 'create',
|
||||||
event_type: 'create',
|
title: post.title,
|
||||||
title: post.title,
|
url: post.url,
|
||||||
url: post.url,
|
thumbnail_base: post.thumbnail_base,
|
||||||
thumbnail_base: post.thumbnail_base,
|
tags: post.snapshot_tag_names.join(' '),
|
||||||
tags: post.snapshot_tag_names.join(' '),
|
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
||||||
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
original_created_from: post.original_created_from,
|
||||||
original_created_from: post.original_created_from,
|
original_created_before: post.original_created_before,
|
||||||
original_created_before: post.original_created_before,
|
created_at: post.created_at,
|
||||||
created_at: post.created_at,
|
created_by_user: post.uploaded_user
|
||||||
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
|
end
|
||||||
|
|
||||||
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
|
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
|
||||||
@@ -823,26 +806,24 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
|
|
||||||
it '401 when not logged in' do
|
it '401 when not logged in' do
|
||||||
sign_out
|
sign_out
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag')
|
||||||
post_record, title: 'updated', tags: 'spec_tag')
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
end
|
end
|
||||||
|
|
||||||
it '403 when not member' do
|
it '403 when not member' do
|
||||||
sign_in_as(create(:user, role: 'guest'))
|
sign_in_as(create(:user, role: 'guest'))
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag')
|
||||||
post_record, title: 'updated', tags: 'spec_tag')
|
|
||||||
expect(response).to have_http_status(:forbidden)
|
expect(response).to have_http_status(:forbidden)
|
||||||
end
|
end
|
||||||
|
|
||||||
it '200 and updates title + resync tags when member' do
|
it '200 and updates title + resync tags when member' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
|
# 追加で別タグも作って、更新時に入れ替わることを見る
|
||||||
tn2 = TagName.create!(name: 'spec_tag_2')
|
tn2 = TagName.create!(name: 'spec_tag_2')
|
||||||
Tag.create!(tag_name: tn2, category: :general)
|
Tag.create!(tag_name: tn2, category: :general)
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
post_record,
|
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag_2')
|
tags: 'spec_tag_2')
|
||||||
|
|
||||||
@@ -850,6 +831,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(json).to have_key('tags')
|
expect(json).to have_key('tags')
|
||||||
expect(json['tags']).to be_an(Array)
|
expect(json['tags']).to be_an(Array)
|
||||||
|
|
||||||
|
# show と同様、update 後レスポンスもツリー形式
|
||||||
names = json['tags'].map { |n| n['name'] }
|
names = json['tags'].map { |n| n['name'] }
|
||||||
expect(names).to include('spec_tag_2')
|
expect(names).to include('spec_tag_2')
|
||||||
end
|
end
|
||||||
@@ -864,10 +846,10 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
it 'return 400' do
|
it 'return 400' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
post_record,
|
title: 'updated title',
|
||||||
title: 'updated title',
|
tags: 'nico:nico_tag'
|
||||||
tags: 'nico:nico_tag')
|
)
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request), response.body
|
expect(response).to have_http_status(:bad_request), response.body
|
||||||
end
|
end
|
||||||
@@ -905,11 +887,11 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
it 'replaces parent posts' do
|
it 'replaces parent posts' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
post_record,
|
title: 'updated title',
|
||||||
title: 'updated title',
|
tags: 'spec_tag',
|
||||||
tags: 'spec_tag',
|
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}"
|
||||||
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}")
|
)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
@@ -926,8 +908,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
it 'clears parent posts when parent_post_ids is blank' do
|
it 'clears parent posts when parent_post_ids is blank' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
post_record,
|
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag',
|
tags: 'spec_tag',
|
||||||
parent_post_ids: ''
|
parent_post_ids: ''
|
||||||
@@ -941,8 +922,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
create_post_version_for!(post_record.reload)
|
create_post_version_for!(post_record.reload)
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
post_record,
|
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag',
|
tags: 'spec_tag',
|
||||||
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}"
|
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}"
|
||||||
@@ -963,10 +943,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
it 'returns 422' do
|
it 'returns 422' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
base_version = create_post_version_for!(post_record.reload)
|
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: {
|
put "/posts/#{post_record.id}", params: {
|
||||||
base_version_no: base_version.version_no,
|
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag' }
|
tags: 'spec_tag' }
|
||||||
|
|
||||||
@@ -989,8 +966,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
parent_post:
|
parent_post:
|
||||||
)
|
)
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
post_record,
|
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag',
|
tags: 'spec_tag',
|
||||||
parent_post_ids: 'abc'
|
parent_post_ids: 'abc'
|
||||||
@@ -1015,8 +991,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
parent_post:
|
parent_post:
|
||||||
)
|
)
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
post_record,
|
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag',
|
tags: 'spec_tag',
|
||||||
parent_post_ids: '999999999'
|
parent_post_ids: '999999999'
|
||||||
@@ -1031,8 +1006,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
it 'returns 422 and does not create self implication' do
|
it 'returns 422 and does not create self implication' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
post_record,
|
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag',
|
tags: 'spec_tag',
|
||||||
parent_post_ids: post_record.id.to_s
|
parent_post_ids: post_record.id.to_s
|
||||||
@@ -1046,221 +1020,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
)).to be(false)
|
)).to be(false)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
describe 'GET /posts/random' do
|
describe 'GET /posts/random' do
|
||||||
@@ -1675,14 +1434,13 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
|
|
||||||
it 'creates next version on PUT /posts/:id when snapshot changes' do
|
it 'creates next version on PUT /posts/:id when snapshot changes' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
base_version = create_post_version_for!(post_record)
|
create_post_version_for!(post_record)
|
||||||
|
|
||||||
tag_name2 = TagName.create!(name: 'spec_tag_2')
|
tag_name2 = TagName.create!(name: 'spec_tag_2')
|
||||||
Tag.create!(tag_name: tag_name2, category: :general)
|
Tag.create!(tag_name: tag_name2, category: :general)
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
put "/posts/#{post_record.id}", params: post_write_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
base_version_no: base_version.version_no,
|
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag_2')
|
tags: 'spec_tag_2')
|
||||||
end.to change(PostVersion, :count).by(1)
|
end.to change(PostVersion, :count).by(1)
|
||||||
@@ -1701,15 +1459,13 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
|
PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
|
||||||
base_version = create_post_version_for!(post_record.reload)
|
create_post_version_for!(post_record.reload)
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
put "/posts/#{post_record.id}", params: post_write_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
base_version_no: base_version.version_no,
|
|
||||||
title: post_record.title,
|
title: post_record.title,
|
||||||
tags: 'spec_tag')
|
tags: 'spec_tag')
|
||||||
}.not_to change(PostVersion, :count)
|
}.not_to change(PostVersion, :count)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
version = post_record.reload.post_versions.order(:version_no).last
|
version = post_record.reload.post_versions.order(:version_no).last
|
||||||
@@ -1734,11 +1490,10 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
|
|
||||||
it 'does not create a version when PUT /posts/:id is invalid' do
|
it 'does not create a version when PUT /posts/:id is invalid' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
base_version = create_post_version_for!(post_record)
|
create_post_version_for!(post_record)
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
put "/posts/#{post_record.id}", params: post_write_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
base_version_no: base_version.version_no,
|
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag',
|
tags: 'spec_tag',
|
||||||
original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601,
|
original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601,
|
||||||
@@ -1752,22 +1507,46 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
describe 'tag versioning from post write actions' do
|
describe 'tag versioning from post write actions' do
|
||||||
let(:member) { create(:user, :member) }
|
let(:member) { create(:user, :member) }
|
||||||
|
|
||||||
it 'creates tag snapshot for normalised tags on PUT /posts/:id' do
|
it 'creates tag snapshot for normalised tags on POST /posts' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
base_version = create_post_version_for!(post_record.reload)
|
expect {
|
||||||
|
post '/posts', params: post_write_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)
|
||||||
|
|
||||||
tag_name2 = TagName.create!(name: 'spec_tag_2')
|
tag_name2 = TagName.create!(name: 'spec_tag_2')
|
||||||
tag2 = Tag.create!(tag_name: tag_name2, category: :general)
|
tag2 = Tag.create!(tag_name: tag_name2, category: :general)
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
put "/posts/#{post_record.id}", params: post_write_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
base_version_no: base_version.version_no,
|
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag_2')
|
tags: 'spec_tag_2')
|
||||||
}.to change { tag2.reload.tag_versions.count }.by(1)
|
}.to change { tag2.reload.tag_versions.count }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok), response.body
|
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)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -26,22 +26,17 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
created_by_user:,
|
created_by_user:,
|
||||||
created_at:
|
created_at:
|
||||||
)
|
)
|
||||||
version =
|
TagVersion.create!(
|
||||||
TagVersion.create!(
|
tag: tag,
|
||||||
tag: tag,
|
version_no: version_no,
|
||||||
version_no: version_no,
|
event_type: event_type,
|
||||||
event_type: event_type,
|
name: name,
|
||||||
name: name,
|
category: category,
|
||||||
category: category,
|
aliases: Array(aliases).join(' '),
|
||||||
aliases: Array(aliases).join(' '),
|
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
||||||
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
created_by_user: created_by_user,
|
||||||
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
|
end
|
||||||
|
|
||||||
let!(:v1) do
|
let!(:v1) do
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,646 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 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 test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Full verification
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd backend && bundle exec rspec
|
|
||||||
cd ../frontend && npm run build && npm run lint
|
|
||||||
```
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Release checklist
|
|
||||||
|
|
||||||
- [ ] Backend specs pass
|
|
||||||
- [ ] Frontend build passes
|
|
||||||
- [ ] No pending migrations
|
|
||||||
- [ ] Preview API SSRF checked
|
|
||||||
- [ ] BAN behavior checked
|
|
||||||
- [ ] CSP checked
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Roadmap
|
|
||||||
|
|
||||||
## Public announcement readiness
|
|
||||||
|
|
||||||
- Harden preview API
|
|
||||||
- Tighten material creation permission
|
|
||||||
- Add admin MVP
|
|
||||||
- Improve frontend tests
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -103,16 +103,10 @@ export default (({ post, onSave }: Props) => {
|
|||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
|
|
||||||
setDisabled (true)
|
setDisabled (true)
|
||||||
try
|
await update ({ id: post.id, title, tags, parentPostIds,
|
||||||
{
|
originalCreatedFrom, originalCreatedBefore },
|
||||||
await update ({ id: post.id, title, tags, parentPostIds,
|
{ baseVersionNo: post.versionNo })
|
||||||
originalCreatedFrom, originalCreatedBefore },
|
setDisabled (false)
|
||||||
{ baseVersionNo: post.versionNo })
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setDisabled (false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -120,7 +114,7 @@ export default (({ post, onSave }: Props) => {
|
|||||||
}, [post])
|
}, [post])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
|
<div className="max-w-xl pt-2 space-y-4">
|
||||||
{/* タイトル */}
|
{/* タイトル */}
|
||||||
<div>
|
<div>
|
||||||
<Label>タイトル</Label>
|
<Label>タイトル</Label>
|
||||||
@@ -160,8 +154,10 @@ export default (({ post, onSave }: Props) => {
|
|||||||
{/* 送信 */}
|
{/* 送信 */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled}>
|
disabled={disabled}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
|
||||||
更新
|
更新
|
||||||
</Button>
|
</Button>
|
||||||
</form>)
|
</div>)
|
||||||
}) satisfies FC<Props>
|
}) satisfies FC<Props>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import YoutubeEmbed from 'react-youtube'
|
|||||||
|
|
||||||
import NicoViewer from '@/components/NicoViewer'
|
import NicoViewer from '@/components/NicoViewer'
|
||||||
import TwitterEmbed from '@/components/TwitterEmbed'
|
import TwitterEmbed from '@/components/TwitterEmbed'
|
||||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
|
||||||
|
|
||||||
import type { FC, RefObject } from 'react'
|
import type { FC, RefObject } from 'react'
|
||||||
|
|
||||||
@@ -17,8 +16,6 @@ type Props = {
|
|||||||
|
|
||||||
|
|
||||||
export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
|
export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
|
||||||
const dialogue = useDialogue ()
|
|
||||||
|
|
||||||
const url = new URL (post.url)
|
const url = new URL (post.url)
|
||||||
|
|
||||||
switch (url.hostname.split ('.').slice (-2).join ('.'))
|
switch (url.hostname.split ('.').slice (-2).join ('.'))
|
||||||
@@ -85,17 +82,12 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
|
|||||||
height={360}/>)
|
height={360}/>)
|
||||||
: (
|
: (
|
||||||
<div>
|
<div>
|
||||||
<a href="#" onClick={async e => {
|
<a href="#" onClick={e => {
|
||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
|
setFramed (confirm ('未確認の外部ページを表示します。\n'
|
||||||
setFramed (await dialogue.confirm ({
|
+ '悪意のあるスクリプトが実行される可能性があります。\n'
|
||||||
title: '未確認の外部ページを表示します。',
|
+ '表示しますか?'))
|
||||||
description: (
|
return
|
||||||
<div>
|
|
||||||
<p>悪意のあるスクリプトが実行される可能性があります。</p>
|
|
||||||
<p>表示しますか?</p>
|
|
||||||
</div>),
|
|
||||||
confirmText: '表示' }))
|
|
||||||
}}>
|
}}>
|
||||||
外部ページを表示
|
外部ページを表示
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
|
|||||||
`${ value.slice (0, start) }${ text }${ value.slice (end) }`
|
`${ value.slice (0, start) }${ text }${ value.slice (end) }`
|
||||||
|
|
||||||
|
|
||||||
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
|
type Props =
|
||||||
tags: string
|
& { tags: string
|
||||||
setTags: (tags: string) => void }
|
setTags: (tags: string) => void }
|
||||||
|
& ComponentPropsWithoutRef<'textarea'>
|
||||||
|
|
||||||
|
|
||||||
export default (({ tags, setTags, ...rest }: Props) => {
|
export default (({ tags, setTags, ...rest }: Props) => {
|
||||||
@@ -76,7 +77,6 @@ export default (({ tags, setTags, ...rest }: Props) => {
|
|||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Label>タグ</Label>
|
<Label>タグ</Label>
|
||||||
<TextArea
|
<TextArea
|
||||||
{...rest}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={ev => setTags (ev.target.value)}
|
onChange={ev => setTags (ev.target.value)}
|
||||||
@@ -88,7 +88,8 @@ export default (({ tags, setTags, ...rest }: Props) => {
|
|||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setFocused (false)
|
setFocused (false)
|
||||||
setSuggestionsVsbl (false)
|
setSuggestionsVsbl (false)
|
||||||
}}/>
|
}}
|
||||||
|
{...rest}/>
|
||||||
{focused && (
|
{focused && (
|
||||||
<TagSearchBox
|
<TagSearchBox
|
||||||
suggestions={suggestionsVsbl && suggestions.length > 0
|
suggestions={suggestionsVsbl && suggestions.length > 0
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default (({ posts, onClick }: Props) => {
|
|||||||
layoutId={layoutId}
|
layoutId={layoutId}
|
||||||
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
|
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
|
||||||
'transform-gpu will-change-transform',
|
'transform-gpu will-change-transform',
|
||||||
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
|
(post.childPosts ?? []).length > 0 && 'outline-4 outline-green-500',
|
||||||
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
|
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
onLayoutAnimationStart={() => {
|
onLayoutAnimationStart={() => {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
{...rest}
|
|
||||||
className={cn ('border rounded p-2', className)}
|
className={cn ('border rounded p-2', className)}
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={local}
|
value={local}
|
||||||
@@ -43,5 +42,6 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
|
|||||||
setLocal (v)
|
setLocal (v)
|
||||||
onChange?.(v ? (new Date (v)).toISOString () : null)
|
onChange?.(v ? (new Date (v)).toISOString () : null)
|
||||||
}}
|
}}
|
||||||
onBlur={onBlur}/>)
|
onBlur={onBlur}
|
||||||
|
{...rest}/>)
|
||||||
}) satisfies FC<Props>
|
}) satisfies FC<Props>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Dialog,
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle } from '@/components/ui/dialog'
|
DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
@@ -119,15 +118,13 @@ export default (({ children }: Props) => {
|
|||||||
closeActive (active?.kind !== 'confirm' && null)
|
closeActive (active?.kind !== 'confirm' && null)
|
||||||
}}>
|
}}>
|
||||||
{active && (
|
{active && (
|
||||||
<DialogContent className="px-6 pb-6 pt-7">
|
<DialogContent>
|
||||||
<DialogHeader className="pl-8">
|
<DialogTitle>{active.options.title}</DialogTitle>
|
||||||
<DialogTitle>{active.options.title}</DialogTitle>
|
|
||||||
|
|
||||||
{active.options.description && (
|
{active.options.description && (
|
||||||
<DialogDescription asChild>
|
<DialogDescription asChild>
|
||||||
<div>{active.options.description}</div>
|
<div>{active.options.description}</div>
|
||||||
</DialogDescription>)}
|
</DialogDescription>)}
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{active.kind === 'confirm' && (
|
{active.kind === 'confirm' && (
|
||||||
|
|||||||
@@ -4,47 +4,34 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva (
|
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",
|
||||||
'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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
|
|
||||||
|
|
||||||
destructive:
|
destructive:
|
||||||
'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
|
||||||
outline:
|
outline:
|
||||||
'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',
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
|
||||||
secondary:
|
secondary:
|
||||||
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
ghost:
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
'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: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: "h-10 px-4 py-2",
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: "h-9 rounded-md px-3",
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: "h-11 rounded-md px-8",
|
||||||
icon: 'h-10 w-10',
|
icon: "h-10 w-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
|||||||
@@ -50,16 +50,14 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
className={cn (
|
className={cn (
|
||||||
'absolute left-4 top-4 rounded-full p-1',
|
'absolute right-4 top-4 rounded-full p-1',
|
||||||
'text-slate-500 transition-colors',
|
'text-muted-foreground opacity-70 transition-opacity',
|
||||||
'hover:bg-slate-200 hover:text-slate-900',
|
'hover:bg-accent hover:text-accent-foreground hover:opacity-100',
|
||||||
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2')}>
|
||||||
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
|
|
||||||
<X className="h-4 w-4"/>
|
<X className="h-4 w-4"/>
|
||||||
<span className="sr-only">閉ぢる</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog,
|
import { Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogTitle } from '@/components/ui/dialog'
|
||||||
DialogHeader,
|
|
||||||
DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { apiPost } from '@/lib/api'
|
import { apiPost } from '@/lib/api'
|
||||||
@@ -19,16 +16,10 @@ type Props = { visible: boolean
|
|||||||
|
|
||||||
|
|
||||||
export default ({ visible, onVisibleChange, setUser }: Props) => {
|
export default ({ visible, onVisibleChange, setUser }: Props) => {
|
||||||
const dialogue = useDialogue ()
|
|
||||||
|
|
||||||
const [inputCode, setInputCode] = useState ('')
|
const [inputCode, setInputCode] = useState ('')
|
||||||
|
|
||||||
const handleTransfer = async () => {
|
const handleTransfer = async () => {
|
||||||
if (!(await dialogue.confirm ({
|
if (!(confirm ('引継ぎを行ってもよろしいですか?\n現在のアカウントからはログアウトされます.')))
|
||||||
title: '引継ぎを行ってもよろしいですか?',
|
|
||||||
description: '現在のアカウントからはログアウトされます.',
|
|
||||||
confirmText: '引継ぐ',
|
|
||||||
variant: 'danger' })))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -53,18 +44,14 @@ export default ({ visible, onVisibleChange, setUser }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={visible} onOpenChange={onVisibleChange}>
|
<Dialog open={visible} onOpenChange={onVisibleChange}>
|
||||||
<DialogContent className="px-6 pp-6 pt-7">
|
<DialogContent>
|
||||||
<DialogHeader className="pl-8">
|
<DialogTitle>ほかのブラウザから引継ぐ</DialogTitle>
|
||||||
<DialogTitle>ほかのブラウザから引継ぐ</DialogTitle>
|
<div className="flex gap-2">
|
||||||
<DialogDescription asChild>
|
<Input placeholder="引継ぎコードを入力"
|
||||||
<div className="flex gap-2">
|
value={inputCode}
|
||||||
<Input placeholder="引継ぎコードを入力"
|
onChange={ev => setInputCode (ev.target.value)}/>
|
||||||
value={inputCode}
|
<Button onClick={handleTransfer}>引継ぐ</Button>
|
||||||
onChange={ev => setInputCode (ev.target.value)}/>
|
</div>
|
||||||
<Button onClick={handleTransfer}>引継ぐ</Button>
|
|
||||||
</div>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>)
|
</Dialog>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog,
|
import { Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle } from '@/components/ui/dialog'
|
DialogTitle } from '@/components/ui/dialog'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { apiPost } from '@/lib/api'
|
import { apiPost } from '@/lib/api'
|
||||||
@@ -18,20 +14,11 @@ type Props = { visible: boolean
|
|||||||
|
|
||||||
|
|
||||||
export default ({ visible, onVisibleChange, user, setUser }: Props) => {
|
export default ({ visible, onVisibleChange, user, setUser }: Props) => {
|
||||||
const dialogue = useDialogue ()
|
|
||||||
|
|
||||||
const handleChange = async () => {
|
const handleChange = async () => {
|
||||||
if (!(user))
|
if (!(user))
|
||||||
return
|
return
|
||||||
|
|
||||||
if (!(await dialogue.confirm ({
|
if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.')))
|
||||||
title: '引継ぎコードを再発行しますか?',
|
|
||||||
description: (
|
|
||||||
<div>
|
|
||||||
<p>再発行するとほかのブラウザからはログアウトされます.</p>
|
|
||||||
</div>),
|
|
||||||
confirmText: '再発行',
|
|
||||||
variant: 'danger' })))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
const data = await apiPost<{ code: string }> ('/users/code/renew', { },
|
const data = await apiPost<{ code: string }> ('/users/code/renew', { },
|
||||||
@@ -46,26 +33,21 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={visible} onOpenChange={onVisibleChange}>
|
<Dialog open={visible} onOpenChange={onVisibleChange}>
|
||||||
<DialogContent className="px-6 pb-6 pt-7">
|
<DialogContent>
|
||||||
<DialogHeader className="pl-8">
|
<DialogTitle>引継ぎコード</DialogTitle>
|
||||||
<DialogTitle>引継ぎコード</DialogTitle>
|
<div>
|
||||||
|
<p>あなたの引継ぎコードはこちらです:</p>
|
||||||
<DialogDescription asChild>
|
<div className="m-2">{user?.inheritanceCode}</div>
|
||||||
<div>
|
<p className="mt-1 text-sm text-red-500">
|
||||||
<p>あなたの引継ぎコードはこちらです:</p>
|
このコードはほかの人には教えないでください!
|
||||||
<div className="m-2">{user?.inheritanceCode}</div>
|
</p>
|
||||||
<p className="mt-1 text-sm text-destructive">
|
<div className="my-4">
|
||||||
このコードはほかの人には教えないでください!
|
<Button onClick={handleChange}
|
||||||
</p>
|
className="px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400">
|
||||||
</div>
|
引継ぎコード再発行
|
||||||
</DialogDescription>
|
</Button>
|
||||||
</DialogHeader>
|
</div>
|
||||||
|
</div>
|
||||||
<DialogFooter>
|
|
||||||
<Button onClick={handleChange} variant="destructive">
|
|
||||||
引継ぎコード再発行
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>)
|
</Dialog>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import TagLink from '@/components/TagLink'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import Pagination from '@/components/common/Pagination'
|
import Pagination from '@/components/common/Pagination'
|
||||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
@@ -36,8 +35,6 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
|
|||||||
|
|
||||||
|
|
||||||
export default (() => {
|
export default (() => {
|
||||||
const dialogue = useDialogue ()
|
|
||||||
|
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
const id = query.get ('id')
|
const id = query.get ('id')
|
||||||
@@ -69,11 +66,8 @@ export default (() => {
|
|||||||
const handleRevert = async (e: MouseEvent<HTMLAnchorElement>, change: PostVersion) => {
|
const handleRevert = async (e: MouseEvent<HTMLAnchorElement>, change: PostVersion) => {
|
||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
|
|
||||||
if (!(await dialogue.confirm ({
|
if (!(confirm (`『${ change.title.current || change.url.current }』を版 ${
|
||||||
title: '差戻の確認',
|
change.versionNo } に差戻します.\nよろしいですか?`)))
|
||||||
description: `『${ change.title.current || change.url.current }』を版 ${
|
|
||||||
change.versionNo } に差戻します.\nよろしいですか?`,
|
|
||||||
confirmText: '差戻' })))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
Reference in New Issue
Block a user