| @@ -75,7 +75,7 @@ class PostsController < ApplicationController | |||||
| else | else | ||||
| "posts.#{ order[0] }" | "posts.#{ order[0] }" | ||||
| end | 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) | .limit(limit) | ||||
| .offset(offset) | .offset(offset) | ||||
| .to_a | .to_a | ||||
| @@ -100,23 +100,16 @@ class PostsController < ApplicationController | |||||
| .first | .first | ||||
| return head :not_found unless post | 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 | end | ||||
| def show | def show | ||||
| post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id]) | post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id]) | ||||
| return head :not_found unless post | 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 | end | ||||
| def create | def create | ||||
| @@ -192,6 +185,7 @@ class PostsController < ApplicationController | |||||
| def changes | def changes | ||||
| id = params[:id].presence | id = params[:id].presence | ||||
| tag_id = params[:tag].presence | |||||
| page = (params[:page].presence || 1).to_i | page = (params[:page].presence || 1).to_i | ||||
| limit = (params[:limit].presence || 20).to_i | limit = (params[:limit].presence || 20).to_i | ||||
| @@ -202,6 +196,7 @@ class PostsController < ApplicationController | |||||
| pts = PostTag.with_discarded | pts = PostTag.with_discarded | ||||
| pts = pts.where(post_id: id) if id.present? | 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, | pts = pts.includes(:post, :created_user, :deleted_user, | ||||
| tag: { tag_name: :wiki_page }) | tag: { tag_name: :wiki_page }) | ||||
| @@ -2,18 +2,71 @@ class TagsController < ApplicationController | |||||
| def index | def index | ||||
| post_id = params[:post] | 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? | if post_id.present? | ||||
| Tag.joins(:posts, :tag_name) | Tag.joins(:posts, :tag_name) | ||||
| else | else | ||||
| Tag.joins(:tag_name) | Tag.joins(:tag_name) | ||||
| end | end | ||||
| .includes(:tag_name, tag_name: :wiki_page) | .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 | end | ||||
| def autocomplete | def autocomplete | ||||
| @@ -2,15 +2,19 @@ | |||||
| module PostRepr | module PostRepr | ||||
| BASE = { include: { tags: TagRepr::BASE } }.freeze | |||||
| BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze | |||||
| module_function | 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 | end | ||||
| def many posts | |||||
| posts.map { |p| base(p) } | |||||
| def many posts, current_user = nil | |||||
| posts.map { |p| base(p, current_user) } | |||||
| end | end | ||||
| end | end | ||||
| @@ -2,7 +2,8 @@ | |||||
| module TagRepr | 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 | 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('add') | ||||
| expect(types).to include('remove') | expect(types).to include('remove') | ||||
| end | 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 | end | ||||
| describe 'POST /posts/:id/viewed' do | describe 'POST /posts/:id/viewed' do | ||||
| @@ -11,16 +11,180 @@ RSpec.describe 'Tags API', type: :request do | |||||
| let!(:tn2) { TagName.create!(name: 'unknown') } | let!(:tn2) { TagName.create!(name: 'unknown') } | ||||
| let!(:tag2) { Tag.create!(tag_name: tn2, category: :general) } | 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 | describe 'GET /tags' do | ||||
| it 'returns tags with name' do | |||||
| it 'returns tags with count and metadata' do | |||||
| get '/tags' | get '/tags' | ||||
| expect(response).to have_http_status(:ok) | 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 | ||||
| end | end | ||||
| @@ -37,9 +201,13 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(response).to have_http_status(:ok) | expect(response).to have_http_status(:ok) | ||||
| expect(json).to include( | 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 | ||||
| end | end | ||||
| @@ -61,7 +229,7 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(json).to be_an(Array) | expect(json).to be_an(Array) | ||||
| expect(json.map { |t| t['name'] }).to include('spec_tag') | 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).to have_key('matched_alias') | ||||
| expect(t['matched_alias']).to be(nil) | expect(t['matched_alias']).to be(nil) | ||||
| end | end | ||||
| @@ -73,9 +241,9 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(json).to be_an(Array) | expect(json).to be_an(Array) | ||||
| expect(json.map { |t| t['name'] }).to include('spec_tag') | 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(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 | ||||
| end | end | ||||
| @@ -85,10 +253,14 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(response).to have_http_status(:ok) | 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 | end | ||||
| it 'returns 404 when not found' do | it 'returns 404 when not found' do | ||||
| @@ -97,7 +269,6 @@ RSpec.describe 'Tags API', type: :request do | |||||
| end | end | ||||
| end | end | ||||
| # member? を持つ user を想定(Factory 側で trait 作ってもOK) | |||||
| let(:member_user) { create(:user) } | let(:member_user) { create(:user) } | ||||
| let(:non_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) | allow(non_member_user).to receive(:gte_member?).and_return(false) | ||||
| end | end | ||||
| describe "PATCH /tags/:id" do | |||||
| context "未ログイン" do | |||||
| describe 'PATCH /tags/:id' do | |||||
| context '未ログイン' do | |||||
| before { stub_current_user(nil) } | 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) | expect(response).to have_http_status(:unauthorized) | ||||
| end | end | ||||
| end | end | ||||
| context "ログインしてゐるが member でない" do | |||||
| context 'ログインしてゐるが member でない' do | |||||
| before { stub_current_user(non_member_user) } | 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) | expect(response).to have_http_status(:forbidden) | ||||
| end | end | ||||
| end | end | ||||
| context "member" do | |||||
| context 'member' do | |||||
| before { stub_current_user(member_user) } | 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) | expect(response).to have_http_status(:ok) | ||||
| tag.reload | 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 | 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) | expect(response).to have_http_status(:ok) | ||||
| tag.reload | 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 | 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) | expect(response).to have_http_status(:ok) | ||||
| tag.reload | 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 | 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) | expect(response).to have_http_status(:ok) | ||||
| tag.reload | 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 | 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]) | expect(response.status).to be_in([404, 500]) | ||||
| end | 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]) | expect(response.status).to be_in([422, 500]) | ||||
| end | end | ||||
| end | end | ||||
| @@ -20,7 +20,7 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`, | |||||
| match: 'all', | match: 'all', | ||||
| limit: '20' }) } })).data.posts | 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 fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name) | ||||
| const fetchWikiPages = async () => (await axios.get (`${ API_BASE_URL }/wiki`)).data | 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 PostSearchPage from '@/pages/posts/PostSearchPage' | ||||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | import ServiceUnavailable from '@/pages/ServiceUnavailable' | ||||
| import SettingPage from '@/pages/users/SettingPage' | import SettingPage from '@/pages/users/SettingPage' | ||||
| import TagListPage from '@/pages/tags/TagListPage' | |||||
| import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' | import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' | ||||
| import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | ||||
| import WikiDiffPage from '@/pages/wiki/WikiDiffPage' | import WikiDiffPage from '@/pages/wiki/WikiDiffPage' | ||||
| @@ -47,6 +48,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||||
| <Route path="/posts/search" element={<PostSearchPage/>}/> | <Route path="/posts/search" element={<PostSearchPage/>}/> | ||||
| <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | ||||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | <Route path="/posts/changes" element={<PostHistoryPage/>}/> | ||||
| <Route path="/tags" element={<TagListPage/>}/> | |||||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | ||||
| <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | ||||
| <Route path="/wiki" element={<WikiSearchPage/>}/> | <Route path="/wiki" element={<WikiSearchPage/>}/> | ||||
| @@ -1,5 +1,6 @@ | |||||
| import { useDraggable, useDroppable } from '@dnd-kit/core' | import { useDraggable, useDroppable } from '@dnd-kit/core' | ||||
| import { CSS } from '@dnd-kit/utilities' | import { CSS } from '@dnd-kit/utilities' | ||||
| import { motion } from 'framer-motion' | |||||
| import { useRef } from 'react' | import { useRef } from 'react' | ||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| @@ -14,10 +15,11 @@ type Props = { | |||||
| nestLevel: number | nestLevel: number | ||||
| pathKey: string | pathKey: string | ||||
| parentTagId?: number | 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 dndId = `tag-node:${ pathKey }` | ||||
| const downPosRef = useRef<{ x: number; y: number } | null> (null) | 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')} | className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} | ||||
| {...attributes} | {...attributes} | ||||
| {...listeners}> | {...listeners}> | ||||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||||
| <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> | |||||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||||
| </motion.div> | |||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | }) 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, | useSensor, | ||||
| useSensors } from '@dnd-kit/core' | useSensors } from '@dnd-kit/core' | ||||
| import { restrictToWindowEdges } from '@dnd-kit/modifiers' | 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 DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| @@ -17,8 +18,9 @@ import SectionTitle from '@/components/common/SectionTitle' | |||||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | import SubsectionTitle from '@/components/common/SubsectionTitle' | ||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| import { toast } from '@/components/ui/use-toast' | 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 { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' | ||||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||||
| import { dateString, originalCreatedAtString } from '@/lib/utils' | import { dateString, originalCreatedAtString } from '@/lib/utils' | ||||
| import type { DragEndEvent } from '@dnd-kit/core' | import type { DragEndEvent } from '@dnd-kit/core' | ||||
| @@ -35,28 +37,27 @@ const renderTagTree = ( | |||||
| path: string, | path: string, | ||||
| suppressClickRef: MutableRefObject<boolean>, | suppressClickRef: MutableRefObject<boolean>, | ||||
| parentTagId?: number, | parentTagId?: number, | ||||
| sp?: boolean, | |||||
| ): ReactNode[] => { | ): ReactNode[] => { | ||||
| const key = `${ path }-${ tag.id }` | const key = `${ path }-${ tag.id }` | ||||
| const self = ( | const self = ( | ||||
| <motion.li | |||||
| key={key} | |||||
| layout | |||||
| transition={{ duration: .2, ease: 'easeOut' }} | |||||
| className="mb-1"> | |||||
| <li key={key} className="mb-1"> | |||||
| <DraggableDroppableTagRow | <DraggableDroppableTagRow | ||||
| tag={tag} | tag={tag} | ||||
| nestLevel={nestLevel} | nestLevel={nestLevel} | ||||
| pathKey={key} | pathKey={key} | ||||
| parentTagId={parentTagId} | parentTagId={parentTagId} | ||||
| suppressClickRef={suppressClickRef}/> | |||||
| </motion.li>) | |||||
| suppressClickRef={suppressClickRef} | |||||
| sp={sp}/> | |||||
| </li>) | |||||
| return [ | return [ | ||||
| self, | self, | ||||
| ...((tag.children | ...((tag.children | ||||
| ?.sort ((a, b) => a.name < b.name ? -1 : 1) | ?.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 [activeTagId, setActiveTagId] = useState<number | null> (null) | ||||
| const [dragging, setDragging] = useState (false) | const [dragging, setDragging] = useState (false) | ||||
| const [saving, setSaving] = useState (false) | const [saving, setSaving] = useState (false) | ||||
| const [tags, setTags] = useState ({ } as TagByCategory) | |||||
| const [tags, setTags] = useState (baseTags) | |||||
| const suppressClickRef = useRef (false) | const suppressClickRef = useRef (false) | ||||
| @@ -163,10 +184,9 @@ export default (({ post }: Props) => { | |||||
| useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })) | useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })) | ||||
| const reloadTags = async (): Promise<void> => { | const reloadTags = async (): Promise<void> => { | ||||
| if (!(post)) | |||||
| return | |||||
| setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`))) | setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`))) | ||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||||
| } | } | ||||
| const onDragEnd = async (e: DragEndEvent) => { | 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 (() => { | 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 ( | return ( | ||||
| <SidebarComponent> | <SidebarComponent> | ||||
| @@ -314,60 +310,57 @@ export default (({ post }: Props) => { | |||||
| document.body.style.userSelect = '' | document.body.style.userSelect = '' | ||||
| }} | }} | ||||
| modifiers={[restrictToWindowEdges]}> | 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}> | <DragOverlay adjustScale={false}> | ||||
| <div className="pointer-events-none"> | <div className="pointer-events-none"> | ||||
| @@ -65,30 +65,31 @@ export default (({ posts, onClick }: Props) => { | |||||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | {CATEGORIES.flatMap (cat => cat in tags ? ( | ||||
| tags[cat].map (tag => ( | tags[cat].map (tag => ( | ||||
| <li key={tag.id} className="mb-1"> | <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>))) : [])} | </li>))) : [])} | ||||
| </ul> | </ul> | ||||
| <SectionTitle>関聯</SectionTitle> | <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 ( | return ( | ||||
| @@ -96,23 +97,19 @@ export default (({ posts, onClick }: Props) => { | |||||
| <TagSearch/> | <TagSearch/> | ||||
| <div className="hidden md:block mt-4"> | <div className="hidden md:block mt-4"> | ||||
| {TagBlock} | |||||
| {posts.length > 0 && TagBlock} | |||||
| </div> | </div> | ||||
| <AnimatePresence initial={false}> | <AnimatePresence initial={false}> | ||||
| {tagsVsbl && ( | {tagsVsbl && ( | ||||
| <motion.div | <motion.div | ||||
| key="sptags" | 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' }}> | transition={{ duration: .2, ease: 'easeOut' }}> | ||||
| {TagBlock} | |||||
| {posts.length > 0 && TagBlock} | |||||
| </motion.div>)} | </motion.div>)} | ||||
| </AnimatePresence> | </AnimatePresence> | ||||
| @@ -74,7 +74,7 @@ export default (({ user }: Props) => { | |||||
| { name: '履歴', to: '/posts/changes' }, | { name: '履歴', to: '/posts/changes' }, | ||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | ||||
| { name: 'タグ', to: '/tags', subMenu: [ | { name: 'タグ', to: '/tags', subMenu: [ | ||||
| { name: 'タグ一覧', to: '/tags', visible: false }, | |||||
| { name: 'タグ一覧', to: '/tags', visible: true }, | |||||
| { name: '別名タグ', to: '/tags/aliases', visible: false }, | { name: '別名タグ', to: '/tags/aliases', visible: false }, | ||||
| { name: '上位タグ', to: '/tags/implications', visible: false }, | { name: '上位タグ', to: '/tags/implications', visible: false }, | ||||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | { 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 location = useLocation () | ||||
| const buildTo = (p: number) => { | const buildTo = (p: number) => { | ||||
| @@ -63,19 +63,65 @@ export default (({ page, totalPages, siblingCount = 4 }) => { | |||||
| <nav className="mt-4 flex justify-center" aria-label="Pagination"> | <nav className="mt-4 flex justify-center" aria-label="Pagination"> | ||||
| <div className="flex items-center gap-2"> | <div className="flex items-center gap-2"> | ||||
| {(page > 1) | {(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) => ( | {pages.map ((p, idx) => ( | ||||
| (p === '…') | (p === '…') | ||||
| ? <span key={`dots-${ idx }`}>…</span> | |||||
| ? <span key={`dots-${ idx }`} className="hidden md:block p-2">…</span> | |||||
| : ((p === page) | : ((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) | {(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> | </div> | ||||
| </nav>) | </nav>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -13,6 +13,16 @@ export const CATEGORIES = [ | |||||
| 'nico', | 'nico', | ||||
| ] as const | ] as const | ||||
| export const CATEGORY_NAMES: Record<Category, string> = { | |||||
| deerjikist: 'ニジラー', | |||||
| meme: '原作・ネタ元・ミーム等', | |||||
| character: 'キャラクター', | |||||
| general: '一般', | |||||
| material: '素材', | |||||
| meta: 'メタタグ', | |||||
| nico: 'ニコニコタグ', | |||||
| } as const | |||||
| export const FETCH_POSTS_ORDER_FIELDS = [ | export const FETCH_POSTS_ORDER_FIELDS = [ | ||||
| 'title', | 'title', | ||||
| 'url', | 'url', | ||||
| @@ -5,7 +5,7 @@ import type { FetchPostsParams, Post, PostTagChange } from '@/types' | |||||
| export const fetchPosts = async ( | export const fetchPosts = async ( | ||||
| { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo, | { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo, | ||||
| originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams | |||||
| originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams, | |||||
| ): Promise<{ | ): Promise<{ | ||||
| posts: Post[] | posts: Post[] | ||||
| count: number }> => | count: number }> => | ||||
| @@ -29,14 +29,17 @@ export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/po | |||||
| export const fetchPostChanges = async ( | export const fetchPostChanges = async ( | ||||
| { id, page, limit }: { | |||||
| { id, tag, page, limit }: { | |||||
| id?: string | id?: string | ||||
| tag?: string | |||||
| page: number | page: number | ||||
| limit: number }, | limit: number }, | ||||
| ): Promise<{ | ): Promise<{ | ||||
| changes: PostTagChange[] | changes: PostTagChange[] | ||||
| count: number }> => | 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> => { | 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 { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' | ||||
| import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' | import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' | ||||
| import { fetchTagByName } from '@/lib/tags' | |||||
| import { fetchTagByName, fetchTag, fetchTags } from '@/lib/tags' | |||||
| import { fetchWikiPage, | import { fetchWikiPage, | ||||
| fetchWikiPageByTitle, | fetchWikiPageByTitle, | ||||
| fetchWikiPages } from '@/lib/wiki' | fetchWikiPages } from '@/lib/wiki' | ||||
| import type { FetchPostsOrder } from '@/types' | |||||
| import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' | |||||
| type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> | 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 prefetchPostChanges: Prefetcher = async (qc, url) => { | ||||
| const id = url.searchParams.get ('id') | const id = url.searchParams.get ('id') | ||||
| const tag = url.searchParams.get ('tag') | |||||
| const page = Number (url.searchParams.get ('page') || 1) | const page = Number (url.searchParams.get ('page') || 1) | ||||
| const limit = Number (url.searchParams.get ('limit') || 20) | 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 ({ | 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 => u.pathname === '/wiki', run: prefetchWikiPagesIndex }, | ||||
| { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) | { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) | ||||
| && Boolean (mWiki (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> => { | 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 = { | export const postsKeys = { | ||||
| root: ['posts'] as const, | root: ['posts'] as const, | ||||
| index: (p: FetchPostsParams) => ['posts', 'index', p] as const, | index: (p: FetchPostsParams) => ['posts', 'index', p] as const, | ||||
| show: (id: string) => ['posts', id] as const, | show: (id: string) => ['posts', id] as const, | ||||
| related: (id: string) => ['related', 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 } | ['posts', 'changes', p] as const } | ||||
| export const tagsKeys = { | 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 = { | export const wikiKeys = { | ||||
| root: ['wiki'] as const, | root: ['wiki'] as const, | ||||
| @@ -1,6 +1,38 @@ | |||||
| import { apiGet } from '@/lib/api' | 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> => { | 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 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 = ( | export const originalCreatedAtString = ( | ||||
| f: string | Date | null, | f: string | Date | null, | ||||
| b: 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 { toast } from '@/components/ui/use-toast' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { fetchPost, toggleViewedFlg } from '@/lib/posts' | import { fetchPost, toggleViewedFlg } from '@/lib/posts' | ||||
| import { postsKeys } from '@/lib/queryKeys' | |||||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import NotFound from '@/pages/NotFound' | import NotFound from '@/pages/NotFound' | ||||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | import ServiceUnavailable from '@/pages/ServiceUnavailable' | ||||
| @@ -101,7 +101,7 @@ export default (({ user }: Props) => { | |||||
| </Helmet> | </Helmet> | ||||
| <div className="hidden md:block"> | <div className="hidden md:block"> | ||||
| <TagDetailSidebar post={post ?? null}/> | |||||
| {post && <TagDetailSidebar post={post}/>} | |||||
| </div> | </div> | ||||
| <MainArea className="relative"> | <MainArea className="relative"> | ||||
| @@ -145,6 +145,7 @@ export default (({ user }: Props) => { | |||||
| qc.setQueryData (postsKeys.show (postId), | qc.setQueryData (postsKeys.show (postId), | ||||
| (prev: any) => newPost ?? prev) | (prev: any) => newPost ?? prev) | ||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | qc.invalidateQueries ({ queryKey: postsKeys.root }) | ||||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||||
| toast ({ description: '更新しました.' }) | toast ({ description: '更新しました.' }) | ||||
| }}/> | }}/> | ||||
| </Tab>)} | </Tab>)} | ||||
| @@ -154,7 +155,7 @@ export default (({ user }: Props) => { | |||||
| </MainArea> | </MainArea> | ||||
| <div className="md:hidden"> | <div className="md:hidden"> | ||||
| <TagDetailSidebar post={post ?? null}/> | |||||
| {post && <TagDetailSidebar post={post} sp/>} | |||||
| </div> | </div> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -11,7 +11,8 @@ import Pagination from '@/components/common/Pagination' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { fetchPostChanges } from '@/lib/posts' | 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 { cn, dateString } from '@/lib/utils' | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| @@ -21,15 +22,26 @@ export default (() => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| const id = query.get ('id') | const id = query.get ('id') | ||||
| const tagId = query.get ('tag') | |||||
| const page = Number (query.get ('page') ?? 1) | const page = Number (query.get ('page') ?? 1) | ||||
| const limit = Number (query.get ('limit') ?? 20) | const limit = Number (query.get ('limit') ?? 20) | ||||
| // 投稿列の結合で使用 | // 投稿列の結合で使用 | ||||
| let rowsCnt: number | let rowsCnt: number | ||||
| const { data: tag } = | |||||
| tagId | |||||
| ? useQuery ({ queryKey: tagsKeys.show (tagId), | |||||
| queryFn: () => fetchTag (tagId) }) | |||||
| : { data: null } | |||||
| const { data, isLoading: loading } = useQuery ({ | 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 changes = data?.changes ?? [] | ||||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | const totalPages = data ? Math.ceil (data.count / limit) : 0 | ||||
| @@ -48,6 +60,7 @@ export default (() => { | |||||
| <PageTitle> | <PageTitle> | ||||
| 耕作履歴 | 耕作履歴 | ||||
| {id && <>: 投稿 {<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>}</>} | {id && <>: 投稿 {<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>}</>} | ||||
| {tag && <>(<TagLink tag={tag} withWiki={false} withCount={false}/>)</>} | |||||
| </PageTitle> | </PageTitle> | ||||
| {loading ? 'Loading...' : ( | {loading ? 'Loading...' : ( | ||||
| @@ -5,6 +5,7 @@ import { Helmet } from 'react-helmet-async' | |||||
| import { useLocation, useNavigate } from 'react-router-dom' | import { useLocation, useNavigate } from 'react-router-dom' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| import SortHeader from '@/components/SortHeader' | |||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| import TagSearchBox from '@/components/TagSearchBox' | import TagSearchBox from '@/components/TagSearchBox' | ||||
| import DateTimeField from '@/components/common/DateTimeField' | import DateTimeField from '@/components/common/DateTimeField' | ||||
| @@ -102,28 +103,6 @@ export default (() => { | |||||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | ||||
| }, [location.search]) | }, [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 からのコピペのため,共通化を考へる. | // TODO: TagSearch からのコピペのため,共通化を考へる. | ||||
| const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => { | const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => { | ||||
| setTagsStr (ev.target.value) | setTagsStr (ev.target.value) | ||||
| @@ -188,7 +167,7 @@ export default (() => { | |||||
| setIf (qs, 'updated_from', updatedFrom) | setIf (qs, 'updated_from', updatedFrom) | ||||
| setIf (qs, 'updated_to', updatedTo) | setIf (qs, 'updated_to', updatedTo) | ||||
| qs.set ('match', matchType) | qs.set ('match', matchType) | ||||
| qs.set ('page', String ('1')) | |||||
| qs.set ('page', '1') | |||||
| qs.set ('order', order) | qs.set ('order', order) | ||||
| navigate (`${ location.pathname }?${ qs.toString () }`) | navigate (`${ location.pathname }?${ qs.toString () }`) | ||||
| } | } | ||||
| @@ -207,6 +186,12 @@ export default (() => { | |||||
| search () | search () | ||||
| } | } | ||||
| const defaultDirection = { title: 'asc', | |||||
| url: 'asc', | |||||
| original_created_at: 'desc', | |||||
| created_at: 'desc', | |||||
| updated_at: 'desc' } as const | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| <Helmet> | <Helmet> | ||||
| @@ -339,26 +324,46 @@ export default (() => { | |||||
| <tr> | <tr> | ||||
| <th className="p-2 text-left whitespace-nowrap">投稿</th> | <th className="p-2 text-left whitespace-nowrap">投稿</th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="title" label="タイトル"/> | |||||
| <SortHeader<FetchPostsOrderField> | |||||
| by="title" | |||||
| label="タイトル" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <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> | ||||
| <th className="p-2 text-left whitespace-nowrap">タグ</th> | <th className="p-2 text-left whitespace-nowrap">タグ</th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <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> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <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> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="updated_at" label="更新日時"/> | |||||
| <SortHeader<FetchPostsOrderField> | |||||
| by="updated_at" | |||||
| label="更新日時" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| </tr> | </tr> | ||||
| </thead> | </thead> | ||||
| <tbody> | <tbody> | ||||
| {results.map (row => ( | {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"> | <td className="p-2"> | ||||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> | <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> | ||||
| <motion.div | <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 | limit: number | ||||
| order: FetchPostsOrder } | 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 Menu = MenuItem[] | ||||
| export type MenuItem = { | export type MenuItem = { | ||||
| @@ -81,7 +104,8 @@ export type Post = { | |||||
| originalCreatedFrom: string | null | originalCreatedFrom: string | null | ||||
| originalCreatedBefore: string | null | originalCreatedBefore: string | null | ||||
| createdAt: string | createdAt: string | ||||
| updatedAt: string } | |||||
| updatedAt: string | |||||
| uploadedUser: { id: number; name: string } | null } | |||||
| export type PostTagChange = { | export type PostTagChange = { | ||||
| post: Post | post: Post | ||||
| @@ -102,18 +126,20 @@ export type Tag = { | |||||
| name: string | name: string | ||||
| category: Category | category: Category | ||||
| postCount: number | postCount: number | ||||
| createdAt: string | |||||
| updatedAt: string | |||||
| hasWiki: boolean | hasWiki: boolean | ||||
| children?: Tag[] | children?: Tag[] | ||||
| matchedAlias?: string | null } | matchedAlias?: string | null } | ||||
| export type Theatre = { | 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 = { | export type User = { | ||||
| id: number | id: number | ||||