| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
6dedf4148d | #61 | 4 days ago |
|
|
930d020f2a | #61 | 4 days ago |
|
|
8ccefd9782 | Merge remote-tracking branch 'origin/main' into feature/061 | 1 week ago |
|
|
0eab847ebb | #61 | 1 week ago |
|
|
e399707fbf | #61 | 1 week ago |
|
|
a7afe5f4d5 | #61 | 1 week ago |
|
|
cac4ad7f51 | #61 | 1 week ago |
|
|
be14ae3ee4 | #61 | 1 week ago |
|
|
5581d6e1cc | #61 | 1 week ago |
|
|
1b56176cac | #61 | 1 week ago |
|
|
ea61f4a047 | #61 | 1 week ago |
|
|
c24ffad7dd | #61 | 1 week ago |
|
|
f8e4da6fcb | #61 | 1 week ago |
|
|
790f39e95b | 日づけ不詳の表示修正 | 1 week ago |
| @@ -75,7 +75,7 @@ class PostsController < ApplicationController | |||
| else | |||
| "posts.#{ order[0] }" | |||
| end | |||
| posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, id #{ order[1] }")) | |||
| posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, posts.id #{ order[1] }")) | |||
| .limit(limit) | |||
| .offset(offset) | |||
| .to_a | |||
| @@ -100,23 +100,16 @@ class PostsController < ApplicationController | |||
| .first | |||
| return head :not_found unless post | |||
| viewed = current_user&.viewed?(post) || false | |||
| render json: PostRepr.base(post).merge(viewed:) | |||
| render json: PostRepr.base(post, current_user) | |||
| end | |||
| def show | |||
| post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id]) | |||
| return head :not_found unless post | |||
| viewed = current_user&.viewed?(post) || false | |||
| json = post.as_json | |||
| json['tags'] = build_tag_tree_for(post.tags) | |||
| json['related'] = post.related(limit: 20) | |||
| json['viewed'] = viewed | |||
| render json: | |||
| render json: PostRepr.base(post, current_user) | |||
| .merge(tags: build_tag_tree_for(post.tags), | |||
| related: post.related(limit: 20)) | |||
| end | |||
| def create | |||
| @@ -192,6 +185,7 @@ class PostsController < ApplicationController | |||
| def changes | |||
| id = params[:id].presence | |||
| tag_id = params[:tag].presence | |||
| page = (params[:page].presence || 1).to_i | |||
| limit = (params[:limit].presence || 20).to_i | |||
| @@ -202,6 +196,7 @@ class PostsController < ApplicationController | |||
| pts = PostTag.with_discarded | |||
| pts = pts.where(post_id: id) if id.present? | |||
| pts = pts.where(tag_id:) if tag_id.present? | |||
| pts = pts.includes(:post, :created_user, :deleted_user, | |||
| tag: { tag_name: :wiki_page }) | |||
| @@ -2,18 +2,71 @@ class TagsController < ApplicationController | |||
| def index | |||
| post_id = params[:post] | |||
| tags = | |||
| name = params[:name].presence | |||
| category = params[:category].presence | |||
| post_count_between = (params[:post_count_gte].presence || -1).to_i, | |||
| (params[:post_count_lte].presence || -1).to_i | |||
| post_count_between[0] = nil if post_count_between[0] < 0 | |||
| post_count_between[1] = nil if post_count_between[1] < 0 | |||
| created_between = params[:created_from].presence, params[:created_to].presence | |||
| updated_between = params[:updated_from].presence, params[:updated_to].presence | |||
| order = params[:order].to_s.split(':', 2).map(&:strip) | |||
| unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at']) | |||
| order[0] = 'post_count' | |||
| end | |||
| unless order[1].in?(['asc', 'desc']) | |||
| order[1] = order[0].in?(['name', 'category']) ? 'asc' : 'desc' | |||
| end | |||
| page = (params[:page].presence || 1).to_i | |||
| limit = (params[:limit].presence || 20).to_i | |||
| page = 1 if page < 1 | |||
| limit = 1 if limit < 1 | |||
| offset = (page - 1) * limit | |||
| q = | |||
| if post_id.present? | |||
| Tag.joins(:posts, :tag_name) | |||
| else | |||
| Tag.joins(:tag_name) | |||
| end | |||
| .includes(:tag_name, tag_name: :wiki_page) | |||
| if post_id.present? | |||
| tags = tags.where(posts: { id: post_id }) | |||
| end | |||
| q = q.where(posts: { id: post_id }) if post_id.present? | |||
| q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name | |||
| q = q.where(category: category) if category | |||
| q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0] | |||
| q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1] | |||
| q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0] | |||
| q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1] | |||
| q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0] | |||
| q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1] | |||
| sort_sql = | |||
| case order[0] | |||
| when 'name' | |||
| 'tag_names.name' | |||
| when 'category' | |||
| 'CASE tags.category ' + | |||
| "WHEN 'deerjikist' THEN 0 " + | |||
| "WHEN 'meme' THEN 1 " + | |||
| "WHEN 'character' THEN 2 " + | |||
| "WHEN 'general' THEN 3 " + | |||
| "WHEN 'material' THEN 4 " + | |||
| "WHEN 'meta' THEN 5 " + | |||
| "WHEN 'nico' THEN 6 END" | |||
| else | |||
| "tags.#{ order[0] }" | |||
| end | |||
| tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }")) | |||
| .limit(limit) | |||
| .offset(offset) | |||
| .to_a | |||
| render json: TagRepr.base(tags) | |||
| render json: { tags: TagRepr.base(tags), count: q.size } | |||
| end | |||
| def autocomplete | |||
| @@ -2,15 +2,19 @@ | |||
| module PostRepr | |||
| BASE = { include: { tags: TagRepr::BASE } }.freeze | |||
| BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze | |||
| module_function | |||
| def base post | |||
| post.as_json(BASE) | |||
| def base post, current_user = nil | |||
| json = post.as_json(BASE) | |||
| return json.merge(viewed: false) unless current_user | |||
| viewed = current_user.viewed?(post) | |||
| json.merge(viewed:) | |||
| end | |||
| def many posts | |||
| posts.map { |p| base(p) } | |||
| def many posts, current_user = nil | |||
| posts.map { |p| base(p, current_user) } | |||
| end | |||
| end | |||
| @@ -2,7 +2,8 @@ | |||
| module TagRepr | |||
| BASE = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze | |||
| BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], | |||
| methods: [:name, :has_wiki] }.freeze | |||
| module_function | |||
| @@ -0,0 +1,16 @@ | |||
| # frozen_string_literal: true | |||
| module UserRepr | |||
| BASE = { only: [:id, :name] }.freeze | |||
| module_function | |||
| def base user | |||
| user.as_json(BASE) | |||
| end | |||
| def many users | |||
| users.map { |u| base(u) } | |||
| end | |||
| end | |||
| @@ -667,6 +667,93 @@ RSpec.describe 'Posts API', type: :request do | |||
| expect(types).to include('add') | |||
| expect(types).to include('remove') | |||
| end | |||
| it 'filters history by tag' do | |||
| tn2 = TagName.create!(name: 'history_tag_hit') | |||
| tag2 = Tag.create!(tag_name: tn2, category: :general) | |||
| tn3 = TagName.create!(name: 'history_tag_miss') | |||
| tag3 = Tag.create!(tag_name: tn3, category: :general) | |||
| other_post = Post.create!( | |||
| title: 'other post', | |||
| url: 'https://example.com/history-other' | |||
| ) | |||
| # hit: add | |||
| PostTag.create!(post: post_record, tag: tag2, created_user: member) | |||
| # hit: add + remove | |||
| pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member) | |||
| pt2.discard_by!(member) | |||
| # miss: add + remove | |||
| pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member) | |||
| pt3.discard_by!(member) | |||
| get '/posts/changes', params: { tag: tag2.id } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json).to include('changes', 'count') | |||
| expect(json['count']).to eq(3) | |||
| changes = json.fetch('changes') | |||
| expect(changes.map { |e| e.dig('tag', 'id') }.uniq).to eq([tag2.id]) | |||
| expect(changes.map { |e| e['change_type'] }).to match_array(%w[add add remove]) | |||
| expect(changes.map { |e| e.dig('post', 'id') }).to match_array([ | |||
| post_record.id, | |||
| other_post.id, | |||
| other_post.id | |||
| ]) | |||
| end | |||
| it 'filters history by post and tag together' do | |||
| tn2 = TagName.create!(name: 'history_tag_combo_hit') | |||
| tag2 = Tag.create!(tag_name: tn2, category: :general) | |||
| tn3 = TagName.create!(name: 'history_tag_combo_miss') | |||
| tag3 = Tag.create!(tag_name: tn3, category: :general) | |||
| other_post = Post.create!( | |||
| title: 'other combo post', | |||
| url: 'https://example.com/history-combo-other' | |||
| ) | |||
| # hit | |||
| PostTag.create!(post: post_record, tag: tag2, created_user: member) | |||
| # miss by post | |||
| pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member) | |||
| pt2.discard_by!(member) | |||
| # miss by tag | |||
| pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member) | |||
| pt3.discard_by!(member) | |||
| get '/posts/changes', params: { id: post_record.id, tag: tag2.id } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json).to include('changes', 'count') | |||
| expect(json['count']).to eq(1) | |||
| changes = json.fetch('changes') | |||
| expect(changes.size).to eq(1) | |||
| expect(changes[0]['change_type']).to eq('add') | |||
| expect(changes[0].dig('post', 'id')).to eq(post_record.id) | |||
| expect(changes[0].dig('tag', 'id')).to eq(tag2.id) | |||
| end | |||
| it 'returns empty history when tag does not match' do | |||
| tn2 = TagName.create!(name: 'history_tag_no_hit') | |||
| tag2 = Tag.create!(tag_name: tn2, category: :general) | |||
| get '/posts/changes', params: { tag: tag2.id } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json.fetch('changes')).to eq([]) | |||
| expect(json.fetch('count')).to eq(0) | |||
| end | |||
| end | |||
| describe 'POST /posts/:id/viewed' do | |||
| @@ -11,16 +11,180 @@ RSpec.describe 'Tags API', type: :request do | |||
| let!(:tn2) { TagName.create!(name: 'unknown') } | |||
| let!(:tag2) { Tag.create!(tag_name: tn2, category: :general) } | |||
| def response_tags | |||
| json.fetch('tags') | |||
| end | |||
| def response_names | |||
| response_tags.map { |t| t.fetch('name') } | |||
| end | |||
| describe 'GET /tags' do | |||
| it 'returns tags with name' do | |||
| it 'returns tags with count and metadata' do | |||
| get '/tags' | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json).to include('tags', 'count') | |||
| expect(response_tags).to be_an(Array) | |||
| expect(json['count']).to be_an(Integer) | |||
| expect(json['count']).to be >= response_tags.size | |||
| row = response_tags.find { |t| t['name'] == 'spec_tag' } | |||
| expect(row).to include( | |||
| 'id' => tag.id, | |||
| 'name' => 'spec_tag', | |||
| 'category' => 'general', | |||
| 'post_count' => 1, | |||
| 'has_wiki' => false) | |||
| expect(row).to have_key('created_at') | |||
| expect(row).to have_key('updated_at') | |||
| end | |||
| expect(json).to be_an(Array) | |||
| expect(json).not_to be_empty | |||
| expect(json[0]).to have_key('name') | |||
| expect(json.map { |t| t['name'] }).to include('spec_tag') | |||
| it 'filters tags by post id' do | |||
| get '/tags', params: { post: post.id } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json['count']).to eq(1) | |||
| expect(response_names).to eq(['spec_tag']) | |||
| end | |||
| it 'filters tags by partial name' do | |||
| get '/tags', params: { name: 'spec' } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(response_names).to include('spec_tag') | |||
| expect(response_names).not_to include('unknown') | |||
| end | |||
| it 'filters tags by category' do | |||
| meme = Tag.create!(tag_name: TagName.create!(name: 'meme_only'), category: :meme) | |||
| get '/tags', params: { category: 'meme' } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(response_names).to eq(['meme_only']) | |||
| expect(response_tags.first['id']).to eq(meme.id) | |||
| end | |||
| it 'filters tags by post_count range' do | |||
| low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general) | |||
| mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general) | |||
| high = Tag.create!(tag_name: TagName.create!(name: 'pc_high'), category: :general) | |||
| low.update_columns(post_count: 1) | |||
| mid.update_columns(post_count: 3) | |||
| high.update_columns(post_count: 5) | |||
| get '/tags', params: { | |||
| name: 'pc_', | |||
| post_count_gte: 2, | |||
| post_count_lte: 4, | |||
| order: 'post_count:asc', | |||
| } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(response_names).to eq(['pc_mid']) | |||
| end | |||
| it 'filters tags by created_at range' do | |||
| old_tag = Tag.create!(tag_name: TagName.create!(name: 'created_old'), category: :general) | |||
| new_tag = Tag.create!(tag_name: TagName.create!(name: 'created_new'), category: :general) | |||
| old_time = Time.zone.local(2024, 1, 1, 0, 0, 0) | |||
| new_time = Time.zone.local(2024, 2, 1, 0, 0, 0) | |||
| old_tag.update_columns(created_at: old_time, updated_at: old_time) | |||
| new_tag.update_columns(created_at: new_time, updated_at: new_time) | |||
| get '/tags', params: { | |||
| name: 'created_', | |||
| created_from: Time.zone.local(2024, 1, 15, 0, 0, 0).iso8601, | |||
| order: 'created_at:asc', | |||
| } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(response_names).to eq(['created_new']) | |||
| end | |||
| it 'filters tags by updated_at range' do | |||
| old_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_old'), category: :general) | |||
| new_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_new'), category: :general) | |||
| old_time = Time.zone.local(2024, 3, 1, 0, 0, 0) | |||
| new_time = Time.zone.local(2024, 4, 1, 0, 0, 0) | |||
| old_tag.update_columns(created_at: old_time, updated_at: old_time) | |||
| new_tag.update_columns(created_at: new_time, updated_at: new_time) | |||
| get '/tags', params: { | |||
| name: 'updated_', | |||
| updated_to: Time.zone.local(2024, 3, 15, 0, 0, 0).iso8601, | |||
| order: 'updated_at:asc', | |||
| } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(response_names).to eq(['updated_old']) | |||
| end | |||
| it 'orders tags by custom category order' do | |||
| Tag.create!(tag_name: TagName.create!(name: 'cat_deerjikist'), category: :deerjikist) | |||
| Tag.create!(tag_name: TagName.create!(name: 'cat_meme'), category: :meme) | |||
| Tag.create!(tag_name: TagName.create!(name: 'cat_character'), category: :character) | |||
| Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general) | |||
| Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material) | |||
| Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta) | |||
| Tag.create!(tag_name: TagName.create!(name: 'nico:cat_nico'), category: :nico) | |||
| get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(response_names).to eq(%w[ | |||
| cat_deerjikist | |||
| cat_meme | |||
| cat_character | |||
| cat_general | |||
| cat_material | |||
| cat_meta | |||
| nico:cat_nico | |||
| ]) | |||
| end | |||
| it 'paginates and keeps total count' do | |||
| %w[pag_a pag_b pag_c].each do |name| | |||
| Tag.create!(tag_name: TagName.create!(name:), category: :general) | |||
| end | |||
| get '/tags', params: { name: 'pag_', order: 'name:asc', page: 2, limit: 2 } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json['count']).to eq(3) | |||
| expect(response_names).to eq(%w[pag_c]) | |||
| end | |||
| it 'falls back to default ordering when order is invalid' do | |||
| low = Tag.create!(tag_name: TagName.create!(name: 'fallback_low'), category: :general) | |||
| high = Tag.create!(tag_name: TagName.create!(name: 'fallback_high'), category: :general) | |||
| low.update_columns(post_count: 1) | |||
| high.update_columns(post_count: 9) | |||
| get '/tags', params: { name: 'fallback_', order: 'nope:sideways' } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(response_names.first).to eq('fallback_high') | |||
| end | |||
| it 'normalises invalid page and limit' do | |||
| %w[norm_a norm_b].each do |name| | |||
| Tag.create!(tag_name: TagName.create!(name:), category: :general) | |||
| end | |||
| get '/tags', params: { name: 'norm_', order: 'name:asc', page: 0, limit: 0 } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json['count']).to eq(2) | |||
| expect(response_tags.size).to eq(1) | |||
| expect(response_names).to eq(['norm_a']) | |||
| end | |||
| end | |||
| @@ -37,9 +201,13 @@ RSpec.describe 'Tags API', type: :request do | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json).to include( | |||
| 'id' => tag.id, | |||
| 'name' => 'spec_tag', | |||
| 'category' => 'general') | |||
| 'id' => tag.id, | |||
| 'name' => 'spec_tag', | |||
| 'category' => 'general', | |||
| 'post_count' => 1, | |||
| 'has_wiki' => false) | |||
| expect(json).to have_key('created_at') | |||
| expect(json).to have_key('updated_at') | |||
| end | |||
| end | |||
| @@ -61,7 +229,7 @@ RSpec.describe 'Tags API', type: :request do | |||
| expect(json).to be_an(Array) | |||
| expect(json.map { |t| t['name'] }).to include('spec_tag') | |||
| t = json.find { |t| t['name'] == 'spec_tag' } | |||
| t = json.find { |x| x['name'] == 'spec_tag' } | |||
| expect(t).to have_key('matched_alias') | |||
| expect(t['matched_alias']).to be(nil) | |||
| end | |||
| @@ -73,9 +241,9 @@ RSpec.describe 'Tags API', type: :request do | |||
| expect(json).to be_an(Array) | |||
| expect(json.map { |t| t['name'] }).to include('spec_tag') | |||
| t = json.find { |t| t['name'] == 'spec_tag' } | |||
| t = json.find { |x| x['name'] == 'spec_tag' } | |||
| expect(t['matched_alias']).to eq('unko') | |||
| expect(json.map { |t| t['name'] }).not_to include('unknown') | |||
| expect(json.map { |x| x['name'] }).not_to include('unknown') | |||
| end | |||
| end | |||
| @@ -85,10 +253,14 @@ RSpec.describe 'Tags API', type: :request do | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json).to have_key('id') | |||
| expect(json).to have_key('name') | |||
| expect(json['id']).to eq(tag.id) | |||
| expect(json['name']).to eq('spec_tag') | |||
| expect(json).to include( | |||
| 'id' => tag.id, | |||
| 'name' => 'spec_tag', | |||
| 'category' => 'general', | |||
| 'post_count' => 1, | |||
| 'has_wiki' => false) | |||
| expect(json).to have_key('created_at') | |||
| expect(json).to have_key('updated_at') | |||
| end | |||
| it 'returns 404 when not found' do | |||
| @@ -97,7 +269,6 @@ RSpec.describe 'Tags API', type: :request do | |||
| end | |||
| end | |||
| # member? を持つ user を想定(Factory 側で trait 作ってもOK) | |||
| let(:member_user) { create(:user) } | |||
| let(:non_member_user) { create(:user) } | |||
| @@ -110,87 +281,80 @@ RSpec.describe 'Tags API', type: :request do | |||
| allow(non_member_user).to receive(:gte_member?).and_return(false) | |||
| end | |||
| describe "PATCH /tags/:id" do | |||
| context "未ログイン" do | |||
| describe 'PATCH /tags/:id' do | |||
| context '未ログイン' do | |||
| before { stub_current_user(nil) } | |||
| it "401 を返す" do | |||
| patch "/tags/#{tag.id}", params: { name: "new" } | |||
| it '401 を返す' do | |||
| patch "/tags/#{ tag.id }", params: { name: 'new' } | |||
| expect(response).to have_http_status(:unauthorized) | |||
| end | |||
| end | |||
| context "ログインしてゐるが member でない" do | |||
| context 'ログインしてゐるが member でない' do | |||
| before { stub_current_user(non_member_user) } | |||
| it "403 を返す" do | |||
| patch "/tags/#{tag.id}", params: { name: "new" } | |||
| it '403 を返す' do | |||
| patch "/tags/#{ tag.id }", params: { name: 'new' } | |||
| expect(response).to have_http_status(:forbidden) | |||
| end | |||
| end | |||
| context "member" do | |||
| context 'member' do | |||
| before { stub_current_user(member_user) } | |||
| it "name だけ更新できる" do | |||
| patch "/tags/#{tag.id}", params: { name: "new" } | |||
| it 'name だけ更新できる' do | |||
| patch "/tags/#{ tag.id }", params: { name: 'new' } | |||
| expect(response).to have_http_status(:ok) | |||
| tag.reload | |||
| expect(tag.name).to eq("new") | |||
| expect(tag.category).to eq("general") | |||
| expect(tag.name).to eq('new') | |||
| expect(tag.category).to eq('general') | |||
| json = JSON.parse(response.body) | |||
| expect(json["id"]).to eq(tag.id) | |||
| expect(json["name"]).to eq("new") | |||
| expect(json["category"]).to eq("general") | |||
| body = JSON.parse(response.body) | |||
| expect(body['id']).to eq(tag.id) | |||
| expect(body['name']).to eq('new') | |||
| expect(body['category']).to eq('general') | |||
| end | |||
| it "category だけ更新できる" do | |||
| patch "/tags/#{tag.id}", params: { category: "meme" } | |||
| it 'category だけ更新できる' do | |||
| patch "/tags/#{ tag.id }", params: { category: 'meme' } | |||
| expect(response).to have_http_status(:ok) | |||
| tag.reload | |||
| expect(tag.name).to eq("spec_tag") | |||
| expect(tag.category).to eq("meme") | |||
| expect(tag.name).to eq('spec_tag') | |||
| expect(tag.category).to eq('meme') | |||
| end | |||
| it "空文字は presence により無視され、更新は走らない(値が変わらない)" do | |||
| patch "/tags/#{tag.id}", params: { name: "", category: " " } | |||
| it '空文字は presence により無視され、更新は走らない(値が変わらない)' do | |||
| patch "/tags/#{ tag.id }", params: { name: '', category: ' ' } | |||
| expect(response).to have_http_status(:ok) | |||
| tag.reload | |||
| expect(tag.name).to eq("spec_tag") | |||
| expect(tag.category).to eq("general") | |||
| expect(tag.name).to eq('spec_tag') | |||
| expect(tag.category).to eq('general') | |||
| end | |||
| it "両方更新できる" do | |||
| patch "/tags/#{tag.id}", params: { name: "n", category: "meta" } | |||
| it '両方更新できる' do | |||
| patch "/tags/#{ tag.id }", params: { name: 'n', category: 'meta' } | |||
| expect(response).to have_http_status(:ok) | |||
| tag.reload | |||
| expect(tag.name).to eq("n") | |||
| expect(tag.category).to eq("meta") | |||
| expect(tag.name).to eq('n') | |||
| expect(tag.category).to eq('meta') | |||
| end | |||
| it "存在しない id だと RecordNotFound になる(通常は 404)" do | |||
| # Rails 設定次第で例外がそのまま上がる/404になる | |||
| # APIなら rescue_from で 404 にしてることが多いので、その場合は 404 を期待。 | |||
| patch "/tags/999999999", params: { name: "x" } | |||
| it '存在しない id だと RecordNotFound になる(通常は 404)' do | |||
| patch '/tags/999999999', params: { name: 'x' } | |||
| expect(response.status).to be_in([404, 500]) | |||
| end | |||
| it "バリデーションで update! が失敗したら(通常は 422 か 500)" do | |||
| patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' } | |||
| # rescue_from の実装次第で変はる: | |||
| # - RecordInvalid を 422 にしてるなら 422 | |||
| # - 未処理なら 500 | |||
| it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do | |||
| patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' } | |||
| expect(response.status).to be_in([422, 500]) | |||
| end | |||
| end | |||
| @@ -20,7 +20,7 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`, | |||
| match: 'all', | |||
| limit: '20' }) } })).data.posts | |||
| const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data | |||
| const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data.tags | |||
| const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name) | |||
| const fetchWikiPages = async () => (await axios.get (`${ API_BASE_URL }/wiki`)).data | |||
| @@ -19,6 +19,7 @@ import PostNewPage from '@/pages/posts/PostNewPage' | |||
| import PostSearchPage from '@/pages/posts/PostSearchPage' | |||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | |||
| import SettingPage from '@/pages/users/SettingPage' | |||
| import TagListPage from '@/pages/tags/TagListPage' | |||
| import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' | |||
| import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | |||
| import WikiDiffPage from '@/pages/wiki/WikiDiffPage' | |||
| @@ -47,6 +48,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||
| <Route path="/posts/search" element={<PostSearchPage/>}/> | |||
| <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | |||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | |||
| <Route path="/tags" element={<TagListPage/>}/> | |||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | |||
| <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | |||
| <Route path="/wiki" element={<WikiSearchPage/>}/> | |||
| @@ -1,5 +1,6 @@ | |||
| import { useDraggable, useDroppable } from '@dnd-kit/core' | |||
| import { CSS } from '@dnd-kit/utilities' | |||
| import { motion } from 'framer-motion' | |||
| import { useRef } from 'react' | |||
| import TagLink from '@/components/TagLink' | |||
| @@ -14,10 +15,11 @@ type Props = { | |||
| nestLevel: number | |||
| pathKey: string | |||
| parentTagId?: number | |||
| suppressClickRef: MutableRefObject<boolean> } | |||
| suppressClickRef: MutableRefObject<boolean> | |||
| sp?: boolean } | |||
| export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Props) => { | |||
| export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: Props) => { | |||
| const dndId = `tag-node:${ pathKey }` | |||
| const downPosRef = useRef<{ x: number; y: number } | null> (null) | |||
| @@ -88,6 +90,8 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Pro | |||
| className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} | |||
| {...attributes} | |||
| {...listeners}> | |||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||
| <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> | |||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||
| </motion.div> | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| @@ -0,0 +1,31 @@ | |||
| import { useLocation } from 'react-router-dom' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| export default <T extends string,>({ by, label, currentOrder, defaultDirection }: { | |||
| by: T | |||
| label: string | |||
| currentOrder: `${ T }:${ 'asc' | 'desc' }` | |||
| defaultDirection: Record<T, 'asc' | 'desc'> }) => { | |||
| const [fld, dir] = currentOrder.split (':') | |||
| const location = useLocation () | |||
| const qs = new URLSearchParams (location.search) | |||
| const nextDir = | |||
| (by === fld) | |||
| ? (dir === 'asc' ? 'desc' : 'asc') | |||
| : (defaultDirection[by] || 'desc') | |||
| qs.set ('order', `${ by }:${ nextDir }`) | |||
| qs.set ('page', '1') | |||
| return ( | |||
| <PrefetchLink | |||
| className="text-inherit visited:text-inherit hover:text-inherit" | |||
| to={`${ location.pathname }?${ qs.toString () }`}> | |||
| <span className="font-bold"> | |||
| {label} | |||
| {by === fld && (dir === 'asc' ? ' ▲' : ' ▼')} | |||
| </span> | |||
| </PrefetchLink>) | |||
| } | |||
| @@ -6,8 +6,9 @@ import { DndContext, | |||
| useSensor, | |||
| useSensors } from '@dnd-kit/core' | |||
| import { restrictToWindowEdges } from '@dnd-kit/modifiers' | |||
| import { AnimatePresence, motion } from 'framer-motion' | |||
| import { useEffect, useRef, useState } from 'react' | |||
| import { useQueryClient } from '@tanstack/react-query' | |||
| import { motion } from 'framer-motion' | |||
| import { useEffect, useMemo, useRef, useState } from 'react' | |||
| import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| @@ -17,8 +18,9 @@ import SectionTitle from '@/components/common/SectionTitle' | |||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | |||
| import SidebarComponent from '@/components/layout/SidebarComponent' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { CATEGORIES } from '@/consts' | |||
| import { CATEGORIES, CATEGORY_NAMES } from '@/consts' | |||
| import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' | |||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||
| import { dateString, originalCreatedAtString } from '@/lib/utils' | |||
| import type { DragEndEvent } from '@dnd-kit/core' | |||
| @@ -35,28 +37,27 @@ const renderTagTree = ( | |||
| path: string, | |||
| suppressClickRef: MutableRefObject<boolean>, | |||
| parentTagId?: number, | |||
| sp?: boolean, | |||
| ): ReactNode[] => { | |||
| const key = `${ path }-${ tag.id }` | |||
| const self = ( | |||
| <motion.li | |||
| key={key} | |||
| layout | |||
| transition={{ duration: .2, ease: 'easeOut' }} | |||
| className="mb-1"> | |||
| <li key={key} className="mb-1"> | |||
| <DraggableDroppableTagRow | |||
| tag={tag} | |||
| nestLevel={nestLevel} | |||
| pathKey={key} | |||
| parentTagId={parentTagId} | |||
| suppressClickRef={suppressClickRef}/> | |||
| </motion.li>) | |||
| suppressClickRef={suppressClickRef} | |||
| sp={sp}/> | |||
| </li>) | |||
| return [ | |||
| self, | |||
| ...((tag.children | |||
| ?.sort ((a, b) => a.name < b.name ? -1 : 1) | |||
| .flatMap (child => renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id))) | |||
| .flatMap (child => | |||
| renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id, sp))) | |||
| ?? [])] | |||
| } | |||
| @@ -147,14 +148,34 @@ const DropSlot = ({ cat }: { cat: Category }) => { | |||
| } | |||
| type Props = { post: Post | null } | |||
| type Props = { post: Post; sp?: boolean } | |||
| export default (({ post, sp }: Props) => { | |||
| sp = Boolean (sp) | |||
| const qc = useQueryClient () | |||
| const baseTags = useMemo<TagByCategory> (() => { | |||
| const tagsTmp = { } as TagByCategory | |||
| for (const tag of post.tags) | |||
| { | |||
| if (!(tag.category in tagsTmp)) | |||
| tagsTmp[tag.category] = [] | |||
| tagsTmp[tag.category].push (tag) | |||
| } | |||
| for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[]) | |||
| tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1) | |||
| return tagsTmp | |||
| }, [post]) | |||
| export default (({ post }: Props) => { | |||
| const [activeTagId, setActiveTagId] = useState<number | null> (null) | |||
| const [dragging, setDragging] = useState (false) | |||
| const [saving, setSaving] = useState (false) | |||
| const [tags, setTags] = useState ({ } as TagByCategory) | |||
| const [tags, setTags] = useState (baseTags) | |||
| const suppressClickRef = useRef (false) | |||
| @@ -163,10 +184,9 @@ export default (({ post }: Props) => { | |||
| useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })) | |||
| const reloadTags = async (): Promise<void> => { | |||
| if (!(post)) | |||
| return | |||
| setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`))) | |||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||
| } | |||
| const onDragEnd = async (e: DragEndEvent) => { | |||
| @@ -255,33 +275,9 @@ export default (({ post }: Props) => { | |||
| } | |||
| } | |||
| const categoryNames: Record<Category, string> = { | |||
| deerjikist: 'ニジラー', | |||
| meme: '原作・ネタ元・ミーム等', | |||
| character: 'キャラクター', | |||
| general: '一般', | |||
| material: '素材', | |||
| meta: 'メタタグ', | |||
| nico: 'ニコニコタグ' } | |||
| useEffect (() => { | |||
| if (!(post)) | |||
| return | |||
| const tagsTmp = { } as TagByCategory | |||
| for (const tag of post.tags) | |||
| { | |||
| if (!(tag.category in tagsTmp)) | |||
| tagsTmp[tag.category] = [] | |||
| tagsTmp[tag.category].push (tag) | |||
| } | |||
| for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[]) | |||
| tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1) | |||
| setTags (tagsTmp) | |||
| }, [post]) | |||
| setTags (baseTags) | |||
| }, [baseTags]) | |||
| return ( | |||
| <SidebarComponent> | |||
| @@ -314,60 +310,57 @@ export default (({ post }: Props) => { | |||
| document.body.style.userSelect = '' | |||
| }} | |||
| modifiers={[restrictToWindowEdges]}> | |||
| <motion.div key={post?.id ?? 0} layout> | |||
| {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( | |||
| <motion.div layout className="my-3" key={cat}> | |||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||
| <motion.ul layout> | |||
| <AnimatePresence initial={false}> | |||
| {(tags[cat] ?? []).flatMap (tag => ( | |||
| renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))} | |||
| <DropSlot cat={cat}/> | |||
| </AnimatePresence> | |||
| </motion.ul> | |||
| </motion.div>))} | |||
| {post && ( | |||
| <div> | |||
| <SectionTitle>情報</SectionTitle> | |||
| <ul> | |||
| <li>Id.: {post.id}</li> | |||
| {/* TODO: uploadedUser の取得を対応したらコメント外す */} | |||
| {/* | |||
| <li> | |||
| <>耕作者: </> | |||
| {post.uploadedUser | |||
| ? ( | |||
| <PrefetchLink to={`/users/${ post.uploadedUser.id }`}> | |||
| {post.uploadedUser.name || '名もなきニジラー'} | |||
| </PrefetchLink>) | |||
| : 'bot操作'} | |||
| </li> | |||
| */} | |||
| <li>耕作日時: {dateString (post.createdAt)}</li> | |||
| <li> | |||
| <>リンク: </> | |||
| <a | |||
| className="break-all" | |||
| href={post.url} | |||
| target="_blank" | |||
| rel="noopener noreferrer nofollow"> | |||
| {post.url} | |||
| </a> | |||
| </li> | |||
| <li> | |||
| <>オリジナルの投稿日時: </> | |||
| {originalCreatedAtString (post.originalCreatedFrom, | |||
| post.originalCreatedBefore)} | |||
| </li> | |||
| <li> | |||
| <PrefetchLink to={`/posts/changes?id=${ post.id }`}> | |||
| 履歴 | |||
| </PrefetchLink> | |||
| </li> | |||
| </ul> | |||
| </div>)} | |||
| </motion.div> | |||
| {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( | |||
| <div className="my-3" key={cat}> | |||
| <SubsectionTitle> | |||
| <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}> | |||
| {CATEGORY_NAMES[cat]} | |||
| </motion.div> | |||
| </SubsectionTitle> | |||
| <ul> | |||
| {(tags[cat] ?? []).flatMap (tag => ( | |||
| renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))} | |||
| <DropSlot cat={cat}/> | |||
| </ul> | |||
| </div>))} | |||
| {post && ( | |||
| <motion.div layoutId={`post-info-${ sp }`}> | |||
| <SectionTitle>情報</SectionTitle> | |||
| <ul> | |||
| <li>Id.: {post.id}</li> | |||
| <li> | |||
| <>耕作者: </> | |||
| {post.uploadedUser | |||
| ? ( | |||
| <PrefetchLink to={`/users/${ post.uploadedUser.id }`}> | |||
| {post.uploadedUser.name || '名もなきニジラー'} | |||
| </PrefetchLink>) | |||
| : 'bot操作'} | |||
| </li> | |||
| <li>耕作日時: {dateString (post.createdAt)}</li> | |||
| <li> | |||
| <>リンク: </> | |||
| <a | |||
| className="break-all" | |||
| href={post.url} | |||
| target="_blank" | |||
| rel="noopener noreferrer nofollow"> | |||
| {post.url} | |||
| </a> | |||
| </li> | |||
| <li> | |||
| <>オリジナルの投稿日時: </> | |||
| {originalCreatedAtString (post.originalCreatedFrom, | |||
| post.originalCreatedBefore)} | |||
| </li> | |||
| <li> | |||
| <PrefetchLink to={`/posts/changes?id=${ post.id }`}> | |||
| 履歴 | |||
| </PrefetchLink> | |||
| </li> | |||
| </ul> | |||
| </motion.div>)} | |||
| <DragOverlay adjustScale={false}> | |||
| <div className="pointer-events-none"> | |||
| @@ -65,30 +65,31 @@ export default (({ posts, onClick }: Props) => { | |||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | |||
| tags[cat].map (tag => ( | |||
| <li key={tag.id} className="mb-1"> | |||
| <TagLink tag={tag} prefetch onClick={onClick}/> | |||
| <motion.div layoutId={`tag-${ tag.id }`}> | |||
| <TagLink tag={tag} prefetch onClick={onClick}/> | |||
| </motion.div> | |||
| </li>))) : [])} | |||
| </ul> | |||
| <SectionTitle>関聯</SectionTitle> | |||
| {posts.length > 0 && ( | |||
| <a href="#" | |||
| onClick={ev => { | |||
| ev.preventDefault () | |||
| void ((async () => { | |||
| try | |||
| { | |||
| const data = await apiGet<Post> ('/posts/random', | |||
| { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), | |||
| match: (anyFlg ? 'any' : 'all') } }) | |||
| navigate (`/posts/${ data.id }`) | |||
| } | |||
| catch | |||
| { | |||
| ; | |||
| } | |||
| }) ()) | |||
| }}> | |||
| ランダム | |||
| </a>)} | |||
| <a href="#" | |||
| onClick={ev => { | |||
| ev.preventDefault () | |||
| void ((async () => { | |||
| try | |||
| { | |||
| const data = await apiGet<Post> ('/posts/random', | |||
| { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), | |||
| match: (anyFlg ? 'any' : 'all') } }) | |||
| navigate (`/posts/${ data.id }`) | |||
| } | |||
| catch | |||
| { | |||
| ; | |||
| } | |||
| }) ()) | |||
| }}> | |||
| ランダム | |||
| </a> | |||
| </>) | |||
| return ( | |||
| @@ -96,23 +97,19 @@ export default (({ posts, onClick }: Props) => { | |||
| <TagSearch/> | |||
| <div className="hidden md:block mt-4"> | |||
| {TagBlock} | |||
| {posts.length > 0 && TagBlock} | |||
| </div> | |||
| <AnimatePresence initial={false}> | |||
| {tagsVsbl && ( | |||
| <motion.div | |||
| key="sptags" | |||
| className="md:hidden mt-4" | |||
| variants={{ hidden: { clipPath: 'inset(0 0 100% 0)', | |||
| height: 0 }, | |||
| visible: { clipPath: 'inset(0 0 0% 0)', | |||
| height: 'auto'} }} | |||
| initial="hidden" | |||
| animate="visible" | |||
| exit="hidden" | |||
| className="md:hidden overflow-hidden" | |||
| initial={{ height: 0 }} | |||
| animate={{ height: 'auto' }} | |||
| exit={{ height: 0 }} | |||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||
| {TagBlock} | |||
| {posts.length > 0 && TagBlock} | |||
| </motion.div>)} | |||
| </AnimatePresence> | |||
| @@ -74,7 +74,7 @@ export default (({ user }: Props) => { | |||
| { name: '履歴', to: '/posts/changes' }, | |||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | |||
| { name: 'タグ', to: '/tags', subMenu: [ | |||
| { name: 'タグ一覧', to: '/tags', visible: false }, | |||
| { name: 'タグ一覧', to: '/tags', visible: true }, | |||
| { name: '別名タグ', to: '/tags/aliases', visible: false }, | |||
| { name: '上位タグ', to: '/tags/implications', visible: false }, | |||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | |||
| @@ -48,7 +48,7 @@ const getPages = ( | |||
| } | |||
| export default (({ page, totalPages, siblingCount = 4 }) => { | |||
| export default (({ page, totalPages, siblingCount = 3 }) => { | |||
| const location = useLocation () | |||
| const buildTo = (p: number) => { | |||
| @@ -63,19 +63,65 @@ export default (({ page, totalPages, siblingCount = 4 }) => { | |||
| <nav className="mt-4 flex justify-center" aria-label="Pagination"> | |||
| <div className="flex items-center gap-2"> | |||
| {(page > 1) | |||
| ? <PrefetchLink to={buildTo (page - 1)} aria-label="前のページ"><</PrefetchLink> | |||
| : <span aria-hidden><</span>} | |||
| ? ( | |||
| <> | |||
| <PrefetchLink | |||
| className="md:hidden p-2" | |||
| to={buildTo (1)} | |||
| aria-label="最初のページ"> | |||
| |< | |||
| </PrefetchLink> | |||
| <PrefetchLink | |||
| className="p-2" | |||
| to={buildTo (page - 1)} | |||
| aria-label="前のページ"> | |||
| < | |||
| </PrefetchLink> | |||
| </>) | |||
| : ( | |||
| <> | |||
| <span className="md:hidden p-2" aria-hidden> | |||
| |< | |||
| </span> | |||
| <span className="p-2" aria-hidden> | |||
| < | |||
| </span> | |||
| </>)} | |||
| {pages.map ((p, idx) => ( | |||
| (p === '…') | |||
| ? <span key={`dots-${ idx }`}>…</span> | |||
| ? <span key={`dots-${ idx }`} className="hidden md:block p-2">…</span> | |||
| : ((p === page) | |||
| ? <span key={p} className="font-bold" aria-current="page">{p}</span> | |||
| : <PrefetchLink key={p} to={buildTo (p)}>{p}</PrefetchLink>)))} | |||
| ? <span key={p} className="font-bold p-2" aria-current="page">{p}</span> | |||
| : ( | |||
| <PrefetchLink | |||
| key={p} | |||
| className="hidden md:block p-2" | |||
| to={buildTo (p)}> | |||
| {p} | |||
| </PrefetchLink>))))} | |||
| {(page < totalPages) | |||
| ? <PrefetchLink to={buildTo (page + 1)} aria-label="次のページ">></PrefetchLink> | |||
| : <span aria-hidden>></span>} | |||
| ? ( | |||
| <> | |||
| <PrefetchLink | |||
| className="p-2" | |||
| to={buildTo (page + 1)} | |||
| aria-label="次のページ"> | |||
| > | |||
| </PrefetchLink> | |||
| <PrefetchLink | |||
| className="md:hidden p-2" | |||
| to={buildTo (totalPages)} | |||
| aria-label="最後のページ"> | |||
| >| | |||
| </PrefetchLink> | |||
| </>) | |||
| : ( | |||
| <> | |||
| <span className="p-2" aria-hidden>></span> | |||
| <span className="md:hidden p-2" aria-hidden>>|</span> | |||
| </>)} | |||
| </div> | |||
| </nav>) | |||
| }) satisfies FC<Props> | |||
| @@ -13,6 +13,16 @@ export const CATEGORIES = [ | |||
| 'nico', | |||
| ] as const | |||
| export const CATEGORY_NAMES: Record<Category, string> = { | |||
| deerjikist: 'ニジラー', | |||
| meme: '原作・ネタ元・ミーム等', | |||
| character: 'キャラクター', | |||
| general: '一般', | |||
| material: '素材', | |||
| meta: 'メタタグ', | |||
| nico: 'ニコニコタグ', | |||
| } as const | |||
| export const FETCH_POSTS_ORDER_FIELDS = [ | |||
| 'title', | |||
| 'url', | |||
| @@ -5,7 +5,7 @@ import type { FetchPostsParams, Post, PostTagChange } from '@/types' | |||
| export const fetchPosts = async ( | |||
| { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo, | |||
| originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams | |||
| originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams, | |||
| ): Promise<{ | |||
| posts: Post[] | |||
| count: number }> => | |||
| @@ -29,14 +29,17 @@ export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/po | |||
| export const fetchPostChanges = async ( | |||
| { id, page, limit }: { | |||
| { id, tag, page, limit }: { | |||
| id?: string | |||
| tag?: string | |||
| page: number | |||
| limit: number }, | |||
| ): Promise<{ | |||
| changes: PostTagChange[] | |||
| count: number }> => | |||
| await apiGet ('/posts/changes', { params: { ...(id && { id }), page, limit } }) | |||
| await apiGet ('/posts/changes', { params: { ...(id && { id }), | |||
| ...(tag && { tag }), | |||
| page, limit } }) | |||
| export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { | |||
| @@ -3,12 +3,12 @@ import { match } from 'path-to-regexp' | |||
| import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' | |||
| import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' | |||
| import { fetchTagByName } from '@/lib/tags' | |||
| import { fetchTagByName, fetchTag, fetchTags } from '@/lib/tags' | |||
| import { fetchWikiPage, | |||
| fetchWikiPageByTitle, | |||
| fetchWikiPages } from '@/lib/wiki' | |||
| import type { FetchPostsOrder } from '@/types' | |||
| import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' | |||
| type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> | |||
| @@ -122,12 +122,50 @@ const prefetchPostShow: Prefetcher = async (qc, url) => { | |||
| const prefetchPostChanges: Prefetcher = async (qc, url) => { | |||
| const id = url.searchParams.get ('id') | |||
| const tag = url.searchParams.get ('tag') | |||
| const page = Number (url.searchParams.get ('page') || 1) | |||
| const limit = Number (url.searchParams.get ('limit') || 20) | |||
| if (tag) | |||
| { | |||
| await qc.prefetchQuery ({ | |||
| queryKey: tagsKeys.show (tag), | |||
| queryFn: () => fetchTag (tag) }) | |||
| } | |||
| await qc.prefetchQuery ({ | |||
| queryKey: postsKeys.changes ({ ...(id && { id }), | |||
| ...(tag && { tag }), | |||
| page, limit }), | |||
| queryFn: () => fetchPostChanges ({ ...(id && { id }), | |||
| ...(tag && { tag }), | |||
| page, limit }) }) | |||
| } | |||
| const prefetchTagsIndex: Prefetcher = async (qc, url) => { | |||
| const postRaw = url.searchParams.get ('post') | |||
| const post = postRaw ? Number (postRaw) : null | |||
| const name = url.searchParams.get ('name') ?? '' | |||
| const category = (url.searchParams.get ('category') || null) as Category | null | |||
| const postCountGTE = Number (url.searchParams.get ('post_count_gte') || 1) | |||
| const postCountLTERaw = url.searchParams.get ('post_count_lte') | |||
| const postCountLTE = postCountLTERaw ? Number (postCountLTERaw) : null | |||
| const createdFrom = url.searchParams.get ('created_from') ?? '' | |||
| const createdTo = url.searchParams.get ('created_to') ?? '' | |||
| const updatedFrom = url.searchParams.get ('updated_from') ?? '' | |||
| const updatedTo = url.searchParams.get ('updated_to') ?? '' | |||
| const page = Number (url.searchParams.get ('page') || 1) | |||
| const limit = Number (url.searchParams.get ('limit') || 20) | |||
| const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder | |||
| const keys = { | |||
| post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, | |||
| updatedFrom, updatedTo, page, limit, order } | |||
| await qc.prefetchQuery ({ | |||
| queryKey: postsKeys.changes ({ ...(id && { id }), page, limit }), | |||
| queryFn: () => fetchPostChanges ({ ...(id && { id }), page, limit }) }) | |||
| queryKey: tagsKeys.index (keys), | |||
| queryFn: () => fetchTags (keys) }) | |||
| } | |||
| @@ -141,7 +179,8 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] | |||
| { test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex }, | |||
| { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) | |||
| && Boolean (mWiki (u.pathname))), | |||
| run: prefetchWikiPageShow }] | |||
| run: prefetchWikiPageShow }, | |||
| { test: u => u.pathname === '/tags', run: prefetchTagsIndex }] | |||
| export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | |||
| @@ -1,16 +1,17 @@ | |||
| import type { FetchPostsParams } from '@/types' | |||
| import type { FetchPostsParams, FetchTagsParams } from '@/types' | |||
| export const postsKeys = { | |||
| root: ['posts'] as const, | |||
| index: (p: FetchPostsParams) => ['posts', 'index', p] as const, | |||
| show: (id: string) => ['posts', id] as const, | |||
| related: (id: string) => ['related', id] as const, | |||
| changes: (p: { id?: string; page: number; limit: number }) => | |||
| changes: (p: { id?: string; tag?: string; page: number; limit: number }) => | |||
| ['posts', 'changes', p] as const } | |||
| export const tagsKeys = { | |||
| root: ['tags'] as const, | |||
| show: (name: string) => ['tags', name] as const } | |||
| root: ['tags'] as const, | |||
| index: (p: FetchTagsParams) => ['tags', 'index', p] as const, | |||
| show: (name: string) => ['tags', name] as const } | |||
| export const wikiKeys = { | |||
| root: ['wiki'] as const, | |||
| @@ -1,6 +1,38 @@ | |||
| import { apiGet } from '@/lib/api' | |||
| import type { Tag } from '@/types' | |||
| import type { FetchTagsParams, Tag } from '@/types' | |||
| export const fetchTags = async ( | |||
| { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, | |||
| updatedFrom, updatedTo, page, limit, order }: FetchTagsParams, | |||
| ): Promise<{ tags: Tag[] | |||
| count: number }> => | |||
| await apiGet ('/tags', { params: { | |||
| ...(post != null && { post }), | |||
| ...(name && { name }), | |||
| ...(category && { category }), | |||
| ...(postCountGTE != null && { post_count_gte: postCountGTE }), | |||
| ...(postCountLTE != null && { post_count_lte: postCountLTE }), | |||
| ...(createdFrom && { created_from: createdFrom }), | |||
| ...(createdTo && { created_to: createdTo }), | |||
| ...(updatedFrom && { updated_from: updatedFrom }), | |||
| ...(updatedTo && { updated_to: updatedTo }), | |||
| ...(page && { page }), | |||
| ...(limit && { limit }), | |||
| ...(order && { order }) } }) | |||
| export const fetchTag = async (id: string): Promise<Tag | null> => { | |||
| try | |||
| { | |||
| return await apiGet (`/tags/${ id }`) | |||
| } | |||
| catch | |||
| { | |||
| return null | |||
| } | |||
| } | |||
| export const fetchTagByName = async (name: string): Promise<Tag | null> => { | |||
| @@ -10,17 +10,64 @@ export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Da | |||
| export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs)) | |||
| export const dateString = (d: string | Date): string => | |||
| toDate (d).toLocaleString ('ja-JP-u-ca-japanese') | |||
| export const dateString = ( | |||
| d: string | Date, | |||
| unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null, | |||
| ): string => | |||
| toDate (d).toLocaleString ( | |||
| 'ja-JP-u-ca-japanese', | |||
| { era: 'long', | |||
| year: 'numeric', | |||
| month: (unknown === 'month' ? undefined : 'long'), | |||
| day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric', | |||
| weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short', | |||
| hour: unknown == null || ['second', 'minute'].includes (unknown) ? 'numeric' : undefined, | |||
| minute: unknown == null || unknown === 'second' ? 'numeric' : undefined, | |||
| second: unknown == null ? 'numeric' : undefined }) | |||
| // TODO: 表示形式きしょすぎるので何とかする | |||
| export const originalCreatedAtString = ( | |||
| f: string | Date | null, | |||
| b: string | Date | null, | |||
| ): string => | |||
| ([f ? `${ dateString (f) } 以降` : '', | |||
| b ? `${ dateString (b) } より前` : ''] | |||
| .filter (Boolean) | |||
| .join (' ')) | |||
| || '不明' | |||
| ): string => { | |||
| const from = f ? toDate (f) : null | |||
| const before = b ? toDate (b) : null | |||
| if (from && before) | |||
| { | |||
| const diff = before.getTime () - from.getTime () | |||
| if (diff <= 60_000 /* 1 分 */) | |||
| return dateString (from, 'second') | |||
| if (from.getMinutes () === 0 && before.getMinutes () === 0) | |||
| { | |||
| if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000) | |||
| return dateString (from, 'minute') + ' (分不詳)' | |||
| if (from.getHours () === 0 && before.getHours () === 0) | |||
| { | |||
| if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000) | |||
| return dateString (from, 'hour') + ' (時刻不詳)' | |||
| if (from.getDate () === 1 && before.getDate () === 1) | |||
| { | |||
| if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */) | |||
| return dateString (from, 'day') + ' (日不詳)' | |||
| if (from.getMonth () === 0 && before.getMonth () === 0 | |||
| && (31_449_600_000 /* 364 日 */ <= diff | |||
| && diff < 31_708_800_000 /* 367 日 */)) | |||
| return dateString (from, 'month') + ' (月日不詳)' | |||
| } | |||
| } | |||
| } | |||
| } | |||
| const rtn = ([from ? `${ dateString (from, 'second') }` : '', | |||
| '〜', | |||
| before ? `${ dateString (new Date (before.getTime () - 60_000), 'second') }` : ''] | |||
| .filter (Boolean) | |||
| .join (' ')) | |||
| return rtn === '〜' ? '年月日不詳' : rtn | |||
| } | |||
| @@ -14,7 +14,7 @@ import { Button } from '@/components/ui/button' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { fetchPost, toggleViewedFlg } from '@/lib/posts' | |||
| import { postsKeys } from '@/lib/queryKeys' | |||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||
| import { cn } from '@/lib/utils' | |||
| import NotFound from '@/pages/NotFound' | |||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | |||
| @@ -101,7 +101,7 @@ export default (({ user }: Props) => { | |||
| </Helmet> | |||
| <div className="hidden md:block"> | |||
| <TagDetailSidebar post={post ?? null}/> | |||
| {post && <TagDetailSidebar post={post}/>} | |||
| </div> | |||
| <MainArea className="relative"> | |||
| @@ -145,6 +145,7 @@ export default (({ user }: Props) => { | |||
| qc.setQueryData (postsKeys.show (postId), | |||
| (prev: any) => newPost ?? prev) | |||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||
| toast ({ description: '更新しました.' }) | |||
| }}/> | |||
| </Tab>)} | |||
| @@ -154,7 +155,7 @@ export default (({ user }: Props) => { | |||
| </MainArea> | |||
| <div className="md:hidden"> | |||
| <TagDetailSidebar post={post ?? null}/> | |||
| {post && <TagDetailSidebar post={post} sp/>} | |||
| </div> | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| @@ -11,7 +11,8 @@ import Pagination from '@/components/common/Pagination' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { fetchPostChanges } from '@/lib/posts' | |||
| import { postsKeys } from '@/lib/queryKeys' | |||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||
| import { fetchTag } from '@/lib/tags' | |||
| import { cn, dateString } from '@/lib/utils' | |||
| import type { FC } from 'react' | |||
| @@ -21,15 +22,26 @@ export default (() => { | |||
| const location = useLocation () | |||
| const query = new URLSearchParams (location.search) | |||
| const id = query.get ('id') | |||
| const tagId = query.get ('tag') | |||
| const page = Number (query.get ('page') ?? 1) | |||
| const limit = Number (query.get ('limit') ?? 20) | |||
| // 投稿列の結合で使用 | |||
| let rowsCnt: number | |||
| const { data: tag } = | |||
| tagId | |||
| ? useQuery ({ queryKey: tagsKeys.show (tagId), | |||
| queryFn: () => fetchTag (tagId) }) | |||
| : { data: null } | |||
| const { data, isLoading: loading } = useQuery ({ | |||
| queryKey: postsKeys.changes ({ ...(id && { id }), page, limit }), | |||
| queryFn: () => fetchPostChanges ({ ...(id && { id }), page, limit }) }) | |||
| queryKey: postsKeys.changes ({ ...(id && { id }), | |||
| ...(tagId && { tag: tagId }), | |||
| page, limit }), | |||
| queryFn: () => fetchPostChanges ({ ...(id && { id }), | |||
| ...(tagId && { tag: tagId }), | |||
| page, limit }) }) | |||
| const changes = data?.changes ?? [] | |||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | |||
| @@ -48,6 +60,7 @@ export default (() => { | |||
| <PageTitle> | |||
| 耕作履歴 | |||
| {id && <>: 投稿 {<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>}</>} | |||
| {tag && <>(<TagLink tag={tag} withWiki={false} withCount={false}/>)</>} | |||
| </PageTitle> | |||
| {loading ? 'Loading...' : ( | |||
| @@ -5,6 +5,7 @@ import { Helmet } from 'react-helmet-async' | |||
| import { useLocation, useNavigate } from 'react-router-dom' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import SortHeader from '@/components/SortHeader' | |||
| import TagLink from '@/components/TagLink' | |||
| import TagSearchBox from '@/components/TagSearchBox' | |||
| import DateTimeField from '@/components/common/DateTimeField' | |||
| @@ -102,28 +103,6 @@ export default (() => { | |||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | |||
| }, [location.search]) | |||
| const SortHeader = ({ by, label }: { by: FetchPostsOrderField; label: string }) => { | |||
| const [fld, dir] = order.split (':') | |||
| const qs = new URLSearchParams (location.search) | |||
| const nextDir = | |||
| (by === fld) | |||
| ? (dir === 'asc' ? 'desc' : 'asc') | |||
| : (['title', 'url'].includes (by) ? 'asc' : 'desc') | |||
| qs.set ('order', `${ by }:${ nextDir }`) | |||
| qs.set ('page', '1') | |||
| return ( | |||
| <PrefetchLink | |||
| className="text-inherit visited:text-inherit hover:text-inherit" | |||
| to={`${ location.pathname }?${ qs.toString () }`}> | |||
| <span className="font-bold"> | |||
| {label} | |||
| {by === fld && (dir === 'asc' ? ' ▲' : ' ▼')} | |||
| </span> | |||
| </PrefetchLink>) | |||
| } | |||
| // TODO: TagSearch からのコピペのため,共通化を考へる. | |||
| const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => { | |||
| setTagsStr (ev.target.value) | |||
| @@ -188,7 +167,7 @@ export default (() => { | |||
| setIf (qs, 'updated_from', updatedFrom) | |||
| setIf (qs, 'updated_to', updatedTo) | |||
| qs.set ('match', matchType) | |||
| qs.set ('page', String ('1')) | |||
| qs.set ('page', '1') | |||
| qs.set ('order', order) | |||
| navigate (`${ location.pathname }?${ qs.toString () }`) | |||
| } | |||
| @@ -207,6 +186,12 @@ export default (() => { | |||
| search () | |||
| } | |||
| const defaultDirection = { title: 'asc', | |||
| url: 'asc', | |||
| original_created_at: 'desc', | |||
| created_at: 'desc', | |||
| updated_at: 'desc' } as const | |||
| return ( | |||
| <MainArea> | |||
| <Helmet> | |||
| @@ -339,26 +324,46 @@ export default (() => { | |||
| <tr> | |||
| <th className="p-2 text-left whitespace-nowrap">投稿</th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="title" label="タイトル"/> | |||
| <SortHeader<FetchPostsOrderField> | |||
| by="title" | |||
| label="タイトル" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="url" label="URL"/> | |||
| <SortHeader<FetchPostsOrderField> | |||
| by="url" | |||
| label="URL" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap">タグ</th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="original_created_at" label="オリジナルの投稿日時"/> | |||
| <SortHeader<FetchPostsOrderField> | |||
| by="original_created_at" | |||
| label="オリジナルの投稿日時" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="created_at" label="投稿日時"/> | |||
| <SortHeader<FetchPostsOrderField> | |||
| by="created_at" | |||
| label="投稿日時" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="updated_at" label="更新日時"/> | |||
| <SortHeader<FetchPostsOrderField> | |||
| by="updated_at" | |||
| label="更新日時" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {results.map (row => ( | |||
| <tr key={row.id} className={'even:bg-gray-100 dark:even:bg-gray-700'}> | |||
| <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700"> | |||
| <td className="p-2"> | |||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> | |||
| <motion.div | |||
| @@ -0,0 +1,282 @@ | |||
| import { useQuery } from '@tanstack/react-query' | |||
| import { useEffect, useMemo, useState } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import { useLocation, useNavigate } from 'react-router-dom' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import SortHeader from '@/components/SortHeader' | |||
| import TagLink from '@/components/TagLink' | |||
| import DateTimeField from '@/components/common/DateTimeField' | |||
| import Label from '@/components/common/Label' | |||
| import PageTitle from '@/components/common/PageTitle' | |||
| import Pagination from '@/components/common/Pagination' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { CATEGORIES, CATEGORY_NAMES } from '@/consts' | |||
| import { tagsKeys } from '@/lib/queryKeys' | |||
| import { fetchTags } from '@/lib/tags' | |||
| import { dateString } from '@/lib/utils' | |||
| import type { FC, FormEvent } from 'react' | |||
| import type { Category, FetchTagsOrder, FetchTagsOrderField } from '@/types' | |||
| const setIf = (qs: URLSearchParams, k: string, v: string | null) => { | |||
| const t = v?.trim () | |||
| if (t) | |||
| qs.set (k, t) | |||
| } | |||
| export default (() => { | |||
| const location = useLocation () | |||
| const navigate = useNavigate () | |||
| const query = useMemo (() => new URLSearchParams (location.search), [location.search]) | |||
| const page = Number (query.get ('page') ?? 1) | |||
| const limit = Number (query.get ('limit') ?? 20) | |||
| const qName = query.get ('name') ?? '' | |||
| const qCategory = (query.get ('category') || null) as Category | null | |||
| const qPostCountGTE = Number (query.get ('post_count_gte') ?? 1) | |||
| const qPostCountLTERaw = query.get ('post_count_lte') | |||
| const qPostCountLTE = qPostCountLTERaw ? Number (qPostCountLTERaw) : null | |||
| const qCreatedFrom = query.get ('created_from') ?? '' | |||
| const qCreatedTo = query.get ('created_to') ?? '' | |||
| const qUpdatedFrom = query.get ('updated_from') ?? '' | |||
| const qUpdatedTo = query.get ('updated_to') ?? '' | |||
| const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder | |||
| const [name, setName] = useState ('') | |||
| const [category, setCategory] = useState<Category | null> (null) | |||
| const [postCountGTE, setPostCountGTE] = useState (1) | |||
| const [postCountLTE, setPostCountLTE] = useState<number | null> (null) | |||
| const [createdFrom, setCreatedFrom] = useState<string | null> (null) | |||
| const [createdTo, setCreatedTo] = useState<string | null> (null) | |||
| const [updatedFrom, setUpdatedFrom] = useState<string | null> (null) | |||
| const [updatedTo, setUpdatedTo] = useState<string | null> (null) | |||
| const keys = { | |||
| page, limit, order, | |||
| post: null, | |||
| name: qName, | |||
| category: qCategory, | |||
| postCountGTE: qPostCountGTE, | |||
| postCountLTE: qPostCountLTE, | |||
| createdFrom: qCreatedFrom, | |||
| createdTo: qCreatedTo, | |||
| updatedFrom: qUpdatedFrom, | |||
| updatedTo: qUpdatedTo } | |||
| const { data, isLoading: loading } = useQuery ({ | |||
| queryKey: tagsKeys.index (keys), | |||
| queryFn: () => fetchTags (keys) }) | |||
| const results = data?.tags ?? [] | |||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | |||
| useEffect (() => { | |||
| setName (qName) | |||
| setCategory (qCategory) | |||
| setPostCountGTE (qPostCountGTE) | |||
| setPostCountLTE (qPostCountLTE) | |||
| setCreatedFrom (qCreatedFrom) | |||
| setCreatedTo (qCreatedTo) | |||
| setUpdatedFrom (qUpdatedFrom) | |||
| setUpdatedTo (qUpdatedTo) | |||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | |||
| }, [location.search]) | |||
| const handleSearch = (e: FormEvent) => { | |||
| e.preventDefault () | |||
| const qs = new URLSearchParams () | |||
| setIf (qs, 'name', name) | |||
| setIf (qs, 'category', category) | |||
| if (postCountGTE !== 1) | |||
| qs.set ('post_count_gte', String (postCountGTE)) | |||
| if (postCountLTE != null) | |||
| qs.set ('post_count_lte', String (postCountLTE)) | |||
| setIf (qs, 'created_from', createdFrom) | |||
| setIf (qs, 'created_to', createdTo) | |||
| setIf (qs, 'updated_from', updatedFrom) | |||
| setIf (qs, 'updated_to', updatedTo) | |||
| qs.set ('page', '1') | |||
| qs.set ('order', order) | |||
| navigate (`${ location.pathname }?${ qs.toString () }`) | |||
| } | |||
| const defaultDirection = { name: 'asc', | |||
| category: 'asc', | |||
| post_count: 'desc', | |||
| created_at: 'desc', | |||
| updated_at: 'desc' } as const | |||
| return ( | |||
| <MainArea> | |||
| <Helmet> | |||
| <title>タグ | {SITE_TITLE}</title> | |||
| </Helmet> | |||
| <div className="max-w-xl"> | |||
| <PageTitle>タグ</PageTitle> | |||
| <form onSubmit={handleSearch} className="space-y-2"> | |||
| {/* 名前 */} | |||
| <div> | |||
| <Label>名前</Label> | |||
| <input | |||
| type="text" | |||
| value={name} | |||
| onChange={e => setName (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* カテゴリ */} | |||
| <div> | |||
| <Label>カテゴリ</Label> | |||
| <select | |||
| value={category ?? ''} | |||
| onChange={e => setCategory((e.target.value || null) as Category | null)} | |||
| className="w-full border p-2 rounded"> | |||
| <option value=""> </option> | |||
| {CATEGORIES.map (cat => ( | |||
| <option key={cat} value={cat}> | |||
| {CATEGORY_NAMES[cat]} | |||
| </option>))} | |||
| </select> | |||
| </div> | |||
| {/* 広場の投稿数 */} | |||
| <div> | |||
| <Label>広場の投稿数</Label> | |||
| <input | |||
| type="number" | |||
| min="0" | |||
| value={postCountGTE < 0 ? 0 : String (postCountGTE)} | |||
| onChange={e => setPostCountGTE (Number (e.target.value || 0))} | |||
| className="border rounded p-2"/> | |||
| <span className="mx-1">〜</span> | |||
| <input | |||
| type="number" | |||
| min="0" | |||
| value={postCountLTE == null ? '' : String (postCountLTE)} | |||
| onChange={e => setPostCountLTE (e.target.value ? Number (e.target.value) : null)} | |||
| className="border rounded p-2"/> | |||
| </div> | |||
| {/* はじめて記載された日時 */} | |||
| <div> | |||
| <Label>はじめて記載された日時</Label> | |||
| <DateTimeField | |||
| value={createdFrom ?? undefined} | |||
| onChange={setCreatedFrom}/> | |||
| <span className="mx-1">〜</span> | |||
| <DateTimeField | |||
| value={createdTo ?? undefined} | |||
| onChange={setCreatedTo}/> | |||
| </div> | |||
| {/* 定義の更新日時 */} | |||
| <div> | |||
| <Label>定義の更新日時</Label> | |||
| <DateTimeField | |||
| value={updatedFrom ?? undefined} | |||
| onChange={setUpdatedFrom}/> | |||
| <span className="mx-1">〜</span> | |||
| <DateTimeField | |||
| value={updatedTo ?? undefined} | |||
| onChange={setUpdatedTo}/> | |||
| </div> | |||
| <div className="py-3"> | |||
| <button | |||
| type="submit" | |||
| className="bg-blue-500 text-white px-4 py-2 rounded"> | |||
| 検索 | |||
| </button> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| {loading ? 'Loading...' : (results.length > 0 ? ( | |||
| <div className="mt-4"> | |||
| <div className="overflow-x-auto"> | |||
| <table className="w-full min-w-[1200px] table-fixed border-collapse"> | |||
| <colgroup> | |||
| <col className="w-72"/> | |||
| <col className="w-48"/> | |||
| <col className="w-16"/> | |||
| <col className="w-44"/> | |||
| <col className="w-44"/> | |||
| <col className="w-16"/> | |||
| </colgroup> | |||
| <thead className="border-b-2 border-black dark:border-white"> | |||
| <tr> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader<FetchTagsOrderField> | |||
| by="name" | |||
| label="タグ" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader<FetchTagsOrderField> | |||
| by="category" | |||
| label="カテゴリ" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader<FetchTagsOrderField> | |||
| by="post_count" | |||
| label="件数" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader<FetchTagsOrderField> | |||
| by="created_at" | |||
| label="最初の記載日時" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader<FetchTagsOrderField> | |||
| by="updated_at" | |||
| label="更新日時" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"/> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {results.map (row => ( | |||
| <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700"> | |||
| <td className="p-2"> | |||
| <TagLink tag={row} withCount={false}/> | |||
| </td> | |||
| <td className="p-2">{CATEGORY_NAMES[row.category]}</td> | |||
| <td className="p-2 text-right">{row.postCount}</td> | |||
| <td className="p-2">{dateString (row.createdAt)}</td> | |||
| <td className="p-2">{dateString (row.updatedAt)}</td> | |||
| <td className="p-2"> | |||
| <PrefetchLink to={`/posts/changes?tag=${ row.id }`}> | |||
| 耕作履歴 | |||
| </PrefetchLink> | |||
| </td> | |||
| </tr>))} | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </div>) : '結果ないよ(笑)')} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| @@ -26,6 +26,29 @@ export type FetchPostsParams = { | |||
| limit: number | |||
| order: FetchPostsOrder } | |||
| export type FetchTagsOrder = `${ FetchTagsOrderField }:${ 'asc' | 'desc' }` | |||
| export type FetchTagsOrderField = | |||
| | 'name' | |||
| | 'category' | |||
| | 'post_count' | |||
| | 'created_at' | |||
| | 'updated_at' | |||
| export type FetchTagsParams = { | |||
| post: number | null | |||
| name: string | |||
| category: Category | null | |||
| postCountGTE: number | |||
| postCountLTE: number | null | |||
| createdFrom: string | |||
| createdTo: string | |||
| updatedFrom: string | |||
| updatedTo: string | |||
| page: number | |||
| limit: number | |||
| order: FetchTagsOrder } | |||
| export type Menu = MenuItem[] | |||
| export type MenuItem = { | |||
| @@ -81,7 +104,8 @@ export type Post = { | |||
| originalCreatedFrom: string | null | |||
| originalCreatedBefore: string | null | |||
| createdAt: string | |||
| updatedAt: string } | |||
| updatedAt: string | |||
| uploadedUser: { id: number; name: string } | null } | |||
| export type PostTagChange = { | |||
| post: Post | |||
| @@ -102,18 +126,20 @@ export type Tag = { | |||
| name: string | |||
| category: Category | |||
| postCount: number | |||
| createdAt: string | |||
| updatedAt: string | |||
| hasWiki: boolean | |||
| children?: Tag[] | |||
| matchedAlias?: string | null } | |||
| export type Theatre = { | |||
| id: number | |||
| name: string | null | |||
| opensAt: string | |||
| closesAt: string | null | |||
| createdByUser: { id: number; name: string } | |||
| createdAt: string | |||
| updatedAt: string } | |||
| id: number | |||
| name: string | null | |||
| opensAt: string | |||
| closesAt: string | null | |||
| createdByUser: { id: number; name: string } | |||
| createdAt: string | |||
| updatedAt: string } | |||
| export type User = { | |||
| id: number | |||