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)
|
||||
return head :bad_request if force && merge
|
||||
|
||||
base_version_no = parse_base_version_no
|
||||
return head :bad_request if !(force) && !(base_version_no)
|
||||
base_version_no = nil
|
||||
base_version_no = parse_base_version_no unless force
|
||||
|
||||
title = params[:title].presence
|
||||
tag_names = params[:tags].to_s.split
|
||||
@@ -442,11 +442,9 @@ class PostsController < ApplicationController
|
||||
|
||||
def parse_base_version_no
|
||||
version_no = Integer(params[:base_version_no], exception: false)
|
||||
if version_no&.positive?
|
||||
version_no
|
||||
else
|
||||
nil
|
||||
end
|
||||
raise ArgumentError, 'base_version_no は必須です.' unless version_no&.positive?
|
||||
|
||||
version_no
|
||||
end
|
||||
|
||||
def post_snapshot_from_version version
|
||||
|
||||
@@ -16,7 +16,7 @@ class VersionRecorder
|
||||
@record = record_class.unscoped.lock.find(@record.id)
|
||||
latest = latest_version
|
||||
|
||||
validate_version_sequence!(latest)
|
||||
validate_version_sequence! latest
|
||||
|
||||
attrs = snapshot_attributes
|
||||
|
||||
@@ -27,7 +27,7 @@ class VersionRecorder
|
||||
version = version_class.create!(
|
||||
base_attributes(latest).merge(record_key => @record).merge(attrs))
|
||||
|
||||
update_record_version_no!(version.version_no)
|
||||
update_record_version_no! version.version_no
|
||||
|
||||
version
|
||||
end
|
||||
@@ -47,7 +47,7 @@ class VersionRecorder
|
||||
end
|
||||
|
||||
def update_record_version_no! version_no
|
||||
@record.update_columns(version_no:)
|
||||
@record.update_columns version_no: version_no
|
||||
@record.version_no = version_no
|
||||
end
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ RSpec.describe 'Posts API', type: :request do
|
||||
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
|
||||
end
|
||||
|
||||
def create_nico_tag!(name)
|
||||
Tag.find_or_create_by_tag_name!(name, category: :nico)
|
||||
end
|
||||
|
||||
def dummy_upload
|
||||
# 中身は何でもいい(加工処理はスタブしてる)
|
||||
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
||||
@@ -27,34 +23,21 @@ RSpec.describe 'Posts API', type: :request do
|
||||
Post.create!(title:, url:)
|
||||
end
|
||||
|
||||
def create_post_version_for!(post)
|
||||
version =
|
||||
PostVersion.create!(
|
||||
post:,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_base: post.thumbnail_base,
|
||||
tags: post.snapshot_tag_names.join(' '),
|
||||
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
||||
original_created_from: post.original_created_from,
|
||||
original_created_before: post.original_created_before,
|
||||
created_at: post.created_at,
|
||||
created_by_user: post.uploaded_user)
|
||||
|
||||
post.update_columns(version_no: version.version_no) if post.has_attribute?(:version_no)
|
||||
post.version_no = version.version_no if post.respond_to?(:version_no=)
|
||||
|
||||
version
|
||||
end
|
||||
|
||||
def post_update_params(post, params = { })
|
||||
base_version =
|
||||
post.post_versions.order(version_no: :desc).first ||
|
||||
create_post_version_for!(post.reload)
|
||||
|
||||
post_write_params({ base_version_no: base_version.version_no }.merge(params))
|
||||
def create_post_version_for! post
|
||||
PostVersion.create!(
|
||||
post:,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_base: post.thumbnail_base,
|
||||
tags: post.snapshot_tag_names.join(' '),
|
||||
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
||||
original_created_from: post.original_created_from,
|
||||
original_created_before: post.original_created_before,
|
||||
created_at: post.created_at,
|
||||
created_by_user: post.uploaded_user
|
||||
)
|
||||
end
|
||||
|
||||
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
|
||||
sign_out
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
post_record, title: 'updated', tags: 'spec_tag')
|
||||
put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag')
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it '403 when not member' do
|
||||
sign_in_as(create(:user, role: 'guest'))
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
post_record, title: 'updated', tags: 'spec_tag')
|
||||
put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag')
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
|
||||
it '200 and updates title + resync tags when member' do
|
||||
sign_in_as(member)
|
||||
|
||||
# 追加で別タグも作って、更新時に入れ替わることを見る
|
||||
tn2 = TagName.create!(name: 'spec_tag_2')
|
||||
Tag.create!(tag_name: tn2, category: :general)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
post_record,
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag_2')
|
||||
|
||||
@@ -850,6 +831,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(json).to have_key('tags')
|
||||
expect(json['tags']).to be_an(Array)
|
||||
|
||||
# show と同様、update 後レスポンスもツリー形式
|
||||
names = json['tags'].map { |n| n['name'] }
|
||||
expect(names).to include('spec_tag_2')
|
||||
end
|
||||
@@ -864,10 +846,10 @@ RSpec.describe 'Posts API', type: :request do
|
||||
it 'return 400' do
|
||||
sign_in_as(member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
post_record,
|
||||
title: 'updated title',
|
||||
tags: 'nico:nico_tag')
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'updated title',
|
||||
tags: 'nico:nico_tag'
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:bad_request), response.body
|
||||
end
|
||||
@@ -905,11 +887,11 @@ RSpec.describe 'Posts API', type: :request do
|
||||
it 'replaces parent posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
post_record,
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}")
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}"
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
@@ -926,8 +908,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
it 'clears parent posts when parent_post_ids is blank' do
|
||||
sign_in_as(member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
post_record,
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
parent_post_ids: ''
|
||||
@@ -941,8 +922,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
sign_in_as(member)
|
||||
create_post_version_for!(post_record.reload)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
post_record,
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
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
|
||||
sign_in_as(member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
put "/posts/#{post_record.id}", params: {
|
||||
base_version_no: base_version.version_no,
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag' }
|
||||
|
||||
@@ -989,8 +966,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
parent_post:
|
||||
)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
post_record,
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
parent_post_ids: 'abc'
|
||||
@@ -1015,8 +991,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
parent_post:
|
||||
)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
post_record,
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
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
|
||||
sign_in_as(member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
post_record,
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
parent_post_ids: post_record.id.to_s
|
||||
@@ -1046,221 +1020,6 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with optimistic locking' do
|
||||
let!(:no_deerjikist_tag) { Tag.no_deerjikist }
|
||||
|
||||
before do
|
||||
PostTag.create!(post: post_record, tag: no_deerjikist_tag)
|
||||
end
|
||||
|
||||
it '400 when base_version_no is missing without force' do
|
||||
sign_in_as(member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag')
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it '400 when force and merge are both true' do
|
||||
sign_in_as(member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
force: '1',
|
||||
merge: '1')
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it '409 when scalar fields are changed both by current and incoming updates' do
|
||||
sign_in_as(member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
post_record.update!(title: 'updated by other user')
|
||||
PostVersionRecorder.record!(
|
||||
post: post_record.reload,
|
||||
event_type: :update,
|
||||
created_by_user: member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: 'updated by me',
|
||||
tags: "spec_tag #{Tag.no_deerjikist.name}")
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
|
||||
expect(json.fetch('error')).to eq('conflict')
|
||||
expect(json.fetch('base_version_no')).to eq(base_version.version_no)
|
||||
expect(json.fetch('current_version_no')).to eq(2)
|
||||
expect(json.fetch('mergeable')).to be(false)
|
||||
|
||||
conflict_fields = json.fetch('conflicts').map { |change| change.fetch('field') }
|
||||
expect(conflict_fields).to include('title')
|
||||
|
||||
expect(post_record.reload.title).to eq('updated by other user')
|
||||
end
|
||||
|
||||
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
|
||||
sign_in_as(member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
current_tag = Tag.find_or_create_by_tag_name!('current_added_tag', category: :general)
|
||||
PostTag.create!(post: post_record, tag: current_tag, created_user: member)
|
||||
|
||||
PostVersionRecorder.record!(
|
||||
post: post_record.reload,
|
||||
event_type: :update,
|
||||
created_by_user: member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: post_record.title,
|
||||
tags: "spec_tag #{Tag.no_deerjikist.name} incoming_added_tag")
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
|
||||
expect(json.fetch('mergeable')).to be(true)
|
||||
|
||||
tag_change = json.fetch('changes').find { |change| change.fetch('field') == 'tag_names' }
|
||||
expect(tag_change).to be_present
|
||||
expect(tag_change.fetch('conflict')).to be(false)
|
||||
expect(tag_change.fetch('added_by_current')).to include('current_added_tag')
|
||||
expect(tag_change.fetch('added_by_me')).to include('incoming_added_tag')
|
||||
end
|
||||
|
||||
it 'merges non-conflicting stale tag changes when merge is true' do
|
||||
sign_in_as(member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
current_tag = Tag.find_or_create_by_tag_name!('current_merge_tag', category: :general)
|
||||
PostTag.create!(post: post_record, tag: current_tag, created_user: member)
|
||||
|
||||
PostVersionRecorder.record!(
|
||||
post: post_record.reload,
|
||||
event_type: :update,
|
||||
created_by_user: member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: post_record.title,
|
||||
tags: "spec_tag #{Tag.no_deerjikist.name} incoming_merge_tag",
|
||||
merge: '1')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
names = post_record.reload.tags.map(&:name)
|
||||
|
||||
expect(names).to include('spec_tag')
|
||||
expect(names).to include(Tag.no_deerjikist.name)
|
||||
expect(names).to include('current_merge_tag')
|
||||
expect(names).to include('incoming_merge_tag')
|
||||
end
|
||||
|
||||
it 'does not conflict when only nico tags changed after the base version' do
|
||||
sign_in_as(member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
nico_tag = create_nico_tag!('nico:optimistic_lock_nico')
|
||||
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
||||
|
||||
PostVersionRecorder.record!(
|
||||
post: post_record.reload,
|
||||
event_type: :update,
|
||||
created_by_user: member)
|
||||
|
||||
expect(post_record.reload.version_no).to eq(2)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: post_record.title,
|
||||
tags: "spec_tag #{ Tag.no_deerjikist.name }")
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
names = post_record.reload.tags.map(&:name)
|
||||
|
||||
expect(names).to include('spec_tag')
|
||||
expect(names).to include(Tag.no_deerjikist.name)
|
||||
expect(names).to include(nico_tag.name)
|
||||
end
|
||||
|
||||
it 'keeps nico tags even when they are not included in PUT tags' do
|
||||
sign_in_as(member)
|
||||
|
||||
nico_tag = create_nico_tag!('nico:readonly_update_nico')
|
||||
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: 'updated title',
|
||||
tags: "spec_tag #{ Tag.no_deerjikist.name }")
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
names = post_record.reload.tags.map(&:name)
|
||||
|
||||
expect(names).to include('spec_tag')
|
||||
expect(names).to include(Tag.no_deerjikist.name)
|
||||
expect(names).to include(nico_tag.name)
|
||||
end
|
||||
|
||||
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
|
||||
sign_in_as(member)
|
||||
|
||||
nico_tag = create_nico_tag!('nico:relation_source')
|
||||
linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)
|
||||
|
||||
NicoTagRelation.create!(nico_tag:, tag: linked_tag)
|
||||
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
||||
PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: post_record.title,
|
||||
tags: "spec_tag #{ Tag.no_deerjikist.name }")
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
names = post_record.reload.tags.map(&:name)
|
||||
|
||||
expect(names).to include(nico_tag.name)
|
||||
expect(names).to include('spec_tag')
|
||||
expect(names).to include(Tag.no_deerjikist.name)
|
||||
expect(names).not_to include(linked_tag.name)
|
||||
end
|
||||
|
||||
it 'force-updates stale posts without base_version_no' do
|
||||
sign_in_as(member)
|
||||
|
||||
create_post_version_for!(post_record.reload)
|
||||
|
||||
post_record.update!(title: 'updated by other user')
|
||||
PostVersionRecorder.record!(
|
||||
post: post_record.reload,
|
||||
event_type: :update,
|
||||
created_by_user: member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
title: 'forced title',
|
||||
tags: "spec_tag #{Tag.no_deerjikist.name}",
|
||||
force: '1')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(post_record.reload.title).to eq('forced title')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
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.create!(tag_name: tag_name2, category: :general)
|
||||
|
||||
expect do
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag_2')
|
||||
end.to change(PostVersion, :count).by(1)
|
||||
@@ -1701,15 +1459,13 @@ RSpec.describe 'Posts API', type: :request do
|
||||
sign_in_as(member)
|
||||
|
||||
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 {
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: post_record.title,
|
||||
tags: 'spec_tag')
|
||||
}.not_to change(PostVersion, :count)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
version = post_record.reload.post_versions.order(:version_no).last
|
||||
@@ -1734,11 +1490,10 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
it 'does not create a version when PUT /posts/:id is invalid' do
|
||||
sign_in_as(member)
|
||||
base_version = create_post_version_for!(post_record)
|
||||
create_post_version_for!(post_record)
|
||||
|
||||
expect do
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601,
|
||||
@@ -1752,22 +1507,46 @@ RSpec.describe 'Posts API', type: :request do
|
||||
describe 'tag versioning from post write actions' do
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
it 'creates tag snapshot for normalised tags on PUT /posts/:id' do
|
||||
it 'creates tag snapshot for normalised tags on POST /posts' do
|
||||
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')
|
||||
tag2 = Tag.create!(tag_name: tag_name2, category: :general)
|
||||
|
||||
expect {
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag_2')
|
||||
}.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
|
||||
|
||||
@@ -26,22 +26,17 @@ RSpec.describe 'TagVersions API', type: :request do
|
||||
created_by_user:,
|
||||
created_at:
|
||||
)
|
||||
version =
|
||||
TagVersion.create!(
|
||||
tag: tag,
|
||||
version_no: version_no,
|
||||
event_type: event_type,
|
||||
name: name,
|
||||
category: category,
|
||||
aliases: Array(aliases).join(' '),
|
||||
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
||||
created_by_user: created_by_user,
|
||||
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
|
||||
TagVersion.create!(
|
||||
tag: tag,
|
||||
version_no: version_no,
|
||||
event_type: event_type,
|
||||
name: name,
|
||||
category: category,
|
||||
aliases: Array(aliases).join(' '),
|
||||
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
||||
created_by_user: created_by_user,
|
||||
created_at: created_at
|
||||
)
|
||||
end
|
||||
|
||||
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 ()
|
||||
|
||||
setDisabled (true)
|
||||
try
|
||||
{
|
||||
await update ({ id: post.id, title, tags, parentPostIds,
|
||||
originalCreatedFrom, originalCreatedBefore },
|
||||
{ baseVersionNo: post.versionNo })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setDisabled (false)
|
||||
}
|
||||
await update ({ id: post.id, title, tags, parentPostIds,
|
||||
originalCreatedFrom, originalCreatedBefore },
|
||||
{ baseVersionNo: post.versionNo })
|
||||
setDisabled (false)
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
@@ -120,7 +114,7 @@ export default (({ post, onSave }: Props) => {
|
||||
}, [post])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
|
||||
<div className="max-w-xl pt-2 space-y-4">
|
||||
{/* タイトル */}
|
||||
<div>
|
||||
<Label>タイトル</Label>
|
||||
@@ -160,8 +154,10 @@ export default (({ post, onSave }: Props) => {
|
||||
{/* 送信 */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled}>
|
||||
disabled={disabled}
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
|
||||
更新
|
||||
</Button>
|
||||
</form>)
|
||||
</div>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -3,7 +3,6 @@ import YoutubeEmbed from 'react-youtube'
|
||||
|
||||
import NicoViewer from '@/components/NicoViewer'
|
||||
import TwitterEmbed from '@/components/TwitterEmbed'
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
|
||||
import type { FC, RefObject } from 'react'
|
||||
|
||||
@@ -17,8 +16,6 @@ type Props = {
|
||||
|
||||
|
||||
export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
|
||||
const dialogue = useDialogue ()
|
||||
|
||||
const url = new URL (post.url)
|
||||
|
||||
switch (url.hostname.split ('.').slice (-2).join ('.'))
|
||||
@@ -85,17 +82,12 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
|
||||
height={360}/>)
|
||||
: (
|
||||
<div>
|
||||
<a href="#" onClick={async e => {
|
||||
<a href="#" onClick={e => {
|
||||
e.preventDefault ()
|
||||
|
||||
setFramed (await dialogue.confirm ({
|
||||
title: '未確認の外部ページを表示します。',
|
||||
description: (
|
||||
<div>
|
||||
<p>悪意のあるスクリプトが実行される可能性があります。</p>
|
||||
<p>表示しますか?</p>
|
||||
</div>),
|
||||
confirmText: '表示' }))
|
||||
setFramed (confirm ('未確認の外部ページを表示します。\n'
|
||||
+ '悪意のあるスクリプトが実行される可能性があります。\n'
|
||||
+ '表示しますか?'))
|
||||
return
|
||||
}}>
|
||||
外部ページを表示
|
||||
</a>
|
||||
|
||||
@@ -31,9 +31,10 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
|
||||
`${ value.slice (0, start) }${ text }${ value.slice (end) }`
|
||||
|
||||
|
||||
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
|
||||
tags: string
|
||||
setTags: (tags: string) => void }
|
||||
type Props =
|
||||
& { tags: string
|
||||
setTags: (tags: string) => void }
|
||||
& ComponentPropsWithoutRef<'textarea'>
|
||||
|
||||
|
||||
export default (({ tags, setTags, ...rest }: Props) => {
|
||||
@@ -76,7 +77,6 @@ export default (({ tags, setTags, ...rest }: Props) => {
|
||||
<div className="relative w-full">
|
||||
<Label>タグ</Label>
|
||||
<TextArea
|
||||
{...rest}
|
||||
ref={ref}
|
||||
value={tags}
|
||||
onChange={ev => setTags (ev.target.value)}
|
||||
@@ -88,7 +88,8 @@ export default (({ tags, setTags, ...rest }: Props) => {
|
||||
onBlur={() => {
|
||||
setFocused (false)
|
||||
setSuggestionsVsbl (false)
|
||||
}}/>
|
||||
}}
|
||||
{...rest}/>
|
||||
{focused && (
|
||||
<TagSearchBox
|
||||
suggestions={suggestionsVsbl && suggestions.length > 0
|
||||
|
||||
@@ -42,7 +42,7 @@ export default (({ posts, onClick }: Props) => {
|
||||
layoutId={layoutId}
|
||||
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
|
||||
'transform-gpu will-change-transform',
|
||||
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
|
||||
(post.childPosts ?? []).length > 0 && 'outline-4 outline-green-500',
|
||||
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onLayoutAnimationStart={() => {
|
||||
|
||||
@@ -34,7 +34,6 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
|
||||
|
||||
return (
|
||||
<input
|
||||
{...rest}
|
||||
className={cn ('border rounded p-2', className)}
|
||||
type="datetime-local"
|
||||
value={local}
|
||||
@@ -43,5 +42,6 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
|
||||
setLocal (v)
|
||||
onChange?.(v ? (new Date (v)).toISOString () : null)
|
||||
}}
|
||||
onBlur={onBlur}/>)
|
||||
onBlur={onBlur}
|
||||
{...rest}/>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle } from '@/components/ui/dialog'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
@@ -119,15 +118,13 @@ export default (({ children }: Props) => {
|
||||
closeActive (active?.kind !== 'confirm' && null)
|
||||
}}>
|
||||
{active && (
|
||||
<DialogContent className="px-6 pb-6 pt-7">
|
||||
<DialogHeader className="pl-8">
|
||||
<DialogTitle>{active.options.title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogTitle>{active.options.title}</DialogTitle>
|
||||
|
||||
{active.options.description && (
|
||||
<DialogDescription asChild>
|
||||
<div>{active.options.description}</div>
|
||||
</DialogDescription>)}
|
||||
</DialogHeader>
|
||||
{active.options.description && (
|
||||
<DialogDescription asChild>
|
||||
<div>{active.options.description}</div>
|
||||
</DialogDescription>)}
|
||||
|
||||
<DialogFooter>
|
||||
{active.kind === 'confirm' && (
|
||||
|
||||
@@ -4,47 +4,34 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva (
|
||||
[
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
|
||||
'rounded-md text-sm font-medium 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 (' '),
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
|
||||
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
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:
|
||||
'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:
|
||||
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
|
||||
|
||||
ghost:
|
||||
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
|
||||
|
||||
link:
|
||||
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300',
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
|
||||
@@ -50,16 +50,14 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
<DialogPrimitive.Close
|
||||
className={cn (
|
||||
'absolute left-4 top-4 rounded-full p-1',
|
||||
'text-slate-500 transition-colors',
|
||||
'hover:bg-slate-200 hover:text-slate-900',
|
||||
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
|
||||
'absolute right-4 top-4 rounded-full p-1',
|
||||
'text-muted-foreground opacity-70 transition-opacity',
|
||||
'hover:bg-accent hover:text-accent-foreground hover:opacity-100',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2')}>
|
||||
<X className="h-4 w-4"/>
|
||||
<span className="sr-only">閉ぢる</span>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle } from '@/components/ui/dialog'
|
||||
DialogContent,
|
||||
DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { apiPost } from '@/lib/api'
|
||||
@@ -19,16 +16,10 @@ type Props = { visible: boolean
|
||||
|
||||
|
||||
export default ({ visible, onVisibleChange, setUser }: Props) => {
|
||||
const dialogue = useDialogue ()
|
||||
|
||||
const [inputCode, setInputCode] = useState ('')
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!(await dialogue.confirm ({
|
||||
title: '引継ぎを行ってもよろしいですか?',
|
||||
description: '現在のアカウントからはログアウトされます.',
|
||||
confirmText: '引継ぐ',
|
||||
variant: 'danger' })))
|
||||
if (!(confirm ('引継ぎを行ってもよろしいですか?\n現在のアカウントからはログアウトされます.')))
|
||||
return
|
||||
|
||||
try
|
||||
@@ -53,18 +44,14 @@ export default ({ visible, onVisibleChange, setUser }: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={onVisibleChange}>
|
||||
<DialogContent className="px-6 pp-6 pt-7">
|
||||
<DialogHeader className="pl-8">
|
||||
<DialogTitle>ほかのブラウザから引継ぐ</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="引継ぎコードを入力"
|
||||
value={inputCode}
|
||||
onChange={ev => setInputCode (ev.target.value)}/>
|
||||
<Button onClick={handleTransfer}>引継ぐ</Button>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent>
|
||||
<DialogTitle>ほかのブラウザから引継ぐ</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="引継ぎコードを入力"
|
||||
value={inputCode}
|
||||
onChange={ev => setInputCode (ev.target.value)}/>
|
||||
<Button onClick={handleTransfer}>引継ぐ</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle } from '@/components/ui/dialog'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { apiPost } from '@/lib/api'
|
||||
@@ -18,20 +14,11 @@ type Props = { visible: boolean
|
||||
|
||||
|
||||
export default ({ visible, onVisibleChange, user, setUser }: Props) => {
|
||||
const dialogue = useDialogue ()
|
||||
|
||||
const handleChange = async () => {
|
||||
if (!(user))
|
||||
return
|
||||
|
||||
if (!(await dialogue.confirm ({
|
||||
title: '引継ぎコードを再発行しますか?',
|
||||
description: (
|
||||
<div>
|
||||
<p>再発行するとほかのブラウザからはログアウトされます.</p>
|
||||
</div>),
|
||||
confirmText: '再発行',
|
||||
variant: 'danger' })))
|
||||
if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.')))
|
||||
return
|
||||
|
||||
const data = await apiPost<{ code: string }> ('/users/code/renew', { },
|
||||
@@ -46,26 +33,21 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={onVisibleChange}>
|
||||
<DialogContent className="px-6 pb-6 pt-7">
|
||||
<DialogHeader className="pl-8">
|
||||
<DialogTitle>引継ぎコード</DialogTitle>
|
||||
|
||||
<DialogDescription asChild>
|
||||
<div>
|
||||
<p>あなたの引継ぎコードはこちらです:</p>
|
||||
<div className="m-2">{user?.inheritanceCode}</div>
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
このコードはほかの人には教えないでください!
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleChange} variant="destructive">
|
||||
引継ぎコード再発行
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogContent>
|
||||
<DialogTitle>引継ぎコード</DialogTitle>
|
||||
<div>
|
||||
<p>あなたの引継ぎコードはこちらです:</p>
|
||||
<div className="m-2">{user?.inheritanceCode}</div>
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
このコードはほかの人には教えないでください!
|
||||
</p>
|
||||
<div className="my-4">
|
||||
<Button onClick={handleChange}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400">
|
||||
引継ぎコード再発行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import TagLink from '@/components/TagLink'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
@@ -36,8 +35,6 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
|
||||
|
||||
|
||||
export default (() => {
|
||||
const dialogue = useDialogue ()
|
||||
|
||||
const location = useLocation ()
|
||||
const query = new URLSearchParams (location.search)
|
||||
const id = query.get ('id')
|
||||
@@ -69,11 +66,8 @@ export default (() => {
|
||||
const handleRevert = async (e: MouseEvent<HTMLAnchorElement>, change: PostVersion) => {
|
||||
e.preventDefault ()
|
||||
|
||||
if (!(await dialogue.confirm ({
|
||||
title: '差戻の確認',
|
||||
description: `『${ change.title.current || change.url.current }』を版 ${
|
||||
change.versionNo } に差戻します.\nよろしいですか?`,
|
||||
confirmText: '差戻' })))
|
||||
if (!(confirm (`『${ change.title.current || change.url.current }』を版 ${
|
||||
change.versionNo } に差戻します.\nよろしいですか?`)))
|
||||
return
|
||||
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user