コミットを比較
3 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 3a13e1b5b2 | |||
| 6b519381b5 | |||
| 3155d0ee40 |
@@ -2,88 +2,41 @@ class PostsController < ApplicationController
|
|||||||
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
|
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
|
||||||
|
|
||||||
def index
|
def index
|
||||||
url = params[:url].presence
|
|
||||||
title = params[:title].presence
|
|
||||||
original_created_from = params[:original_created_from].presence
|
|
||||||
original_created_to = params[:original_created_to].presence
|
|
||||||
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?(['title', 'url', 'original_created_at', 'created_at', 'updated_at'])
|
|
||||||
order[0] = 'original_created_at'
|
|
||||||
end
|
|
||||||
unless order[1].in?(['asc', 'desc'])
|
|
||||||
order[1] =
|
|
||||||
if order[0].in?(['title', 'url'])
|
|
||||||
'asc'
|
|
||||||
else
|
|
||||||
'desc'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
||||||
|
cursor = params[:cursor].presence
|
||||||
|
|
||||||
page = 1 if page < 1
|
page = 1 if page < 1
|
||||||
limit = 1 if limit < 1
|
limit = 1 if limit < 1
|
||||||
|
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
pt_max_sql =
|
sort_sql =
|
||||||
PostTag
|
'COALESCE(posts.original_created_before - INTERVAL 1 SECOND,' +
|
||||||
.select('post_id, MAX(updated_at) AS max_updated_at')
|
'posts.original_created_from,' +
|
||||||
.group('post_id')
|
'posts.created_at)'
|
||||||
.to_sql
|
|
||||||
|
|
||||||
updated_at_all_sql =
|
|
||||||
'GREATEST(posts.updated_at,' +
|
|
||||||
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
|
|
||||||
|
|
||||||
q =
|
q =
|
||||||
filtered_posts
|
filtered_posts
|
||||||
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
|
|
||||||
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
|
||||||
.preload(tags: { tag_name: :wiki_page })
|
.preload(tags: { tag_name: :wiki_page })
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
|
.select("posts.*, #{ sort_sql } AS sort_ts")
|
||||||
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
.order(Arel.sql("#{ sort_sql } DESC"))
|
||||||
q = q.where('posts.title LIKE ?', "%#{ title }%") if title
|
posts =
|
||||||
if original_created_from
|
if cursor
|
||||||
q = q.where('posts.original_created_before > ?', original_created_from)
|
q.where("#{ sort_sql } < ?", Time.iso8601(cursor)).limit(limit + 1)
|
||||||
end
|
|
||||||
if original_created_to
|
|
||||||
q = q.where('posts.original_created_from <= ?', original_created_to)
|
|
||||||
end
|
|
||||||
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
|
|
||||||
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
|
|
||||||
if updated_between[0]
|
|
||||||
q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
|
|
||||||
end
|
|
||||||
if updated_between[1]
|
|
||||||
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
|
|
||||||
end
|
|
||||||
|
|
||||||
sort_sql =
|
|
||||||
case order[0]
|
|
||||||
when 'original_created_at'
|
|
||||||
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE,' +
|
|
||||||
'posts.original_created_from,' +
|
|
||||||
'posts.created_at) '
|
|
||||||
when 'updated_at'
|
|
||||||
updated_at_all_sql
|
|
||||||
else
|
else
|
||||||
"posts.#{ order[0] }"
|
q.limit(limit).offset(offset)
|
||||||
end
|
end
|
||||||
posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, id #{ order[1] }"))
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset)
|
|
||||||
.to_a
|
.to_a
|
||||||
|
|
||||||
q = q.except(:select, :order)
|
next_cursor = nil
|
||||||
|
if cursor && posts.length > limit
|
||||||
|
next_cursor = posts.last.read_attribute('sort_ts').iso8601(6)
|
||||||
|
posts = posts.first(limit)
|
||||||
|
end
|
||||||
|
|
||||||
render json: { posts: posts.map { |post|
|
render json: { posts: posts.map { |post|
|
||||||
PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json|
|
PostRepr.base(post).tap do |json|
|
||||||
json['thumbnail'] =
|
json['thumbnail'] =
|
||||||
if post.thumbnail.attached?
|
if post.thumbnail.attached?
|
||||||
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
||||||
@@ -91,7 +44,11 @@ class PostsController < ApplicationController
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
}, count: q.group_values.present? ? q.count.size : q.count }
|
}, count: if filtered_posts.group_values.present?
|
||||||
|
filtered_posts.count.size
|
||||||
|
else
|
||||||
|
filtered_posts.count
|
||||||
|
end, next_cursor: }
|
||||||
end
|
end
|
||||||
|
|
||||||
def random
|
def random
|
||||||
@@ -106,7 +63,7 @@ class PostsController < ApplicationController
|
|||||||
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(params[:id])
|
||||||
return head :not_found unless post
|
return head :not_found unless post
|
||||||
|
|
||||||
viewed = current_user&.viewed?(post) || false
|
viewed = current_user&.viewed?(post) || false
|
||||||
@@ -245,18 +202,40 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def filter_posts_by_tags tag_names, match_type
|
def filter_posts_by_tags tag_names, match_type
|
||||||
tag_names = TagName.canonicalise(tag_names)
|
literals = tag_names.map do |raw_name|
|
||||||
|
{ name: TagName.canonicalise(raw_name.sub(/\Anot:/i, '')).first,
|
||||||
|
negative: raw_name.downcase.start_with?('not:') }
|
||||||
|
end
|
||||||
|
|
||||||
posts = Post.joins(tags: :tag_name)
|
return Post.all if literals.empty?
|
||||||
|
|
||||||
if match_type == 'any'
|
if match_type == 'any'
|
||||||
posts.where(tag_names: { name: tag_names }).distinct
|
literals.reduce(Post.none) do |posts, literal|
|
||||||
|
posts.or(tag_literal_relation(literal[:name], negative: literal[:negative]))
|
||||||
|
end
|
||||||
else
|
else
|
||||||
posts.where(tag_names: { name: tag_names })
|
literals.reduce(Post.all) do |posts, literal|
|
||||||
.group('posts.id')
|
ids = tagged_post_ids_for(literal[:name])
|
||||||
.having('COUNT(DISTINCT tag_names.id) = ?', tag_names.uniq.size)
|
if literal[:negative]
|
||||||
|
posts.where.not(id: ids)
|
||||||
|
else
|
||||||
|
posts.where(id: ids)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_literal_relation name, negative:
|
||||||
|
ids = tagged_post_ids_for(name)
|
||||||
|
if negative
|
||||||
|
Post.where.not(id: ids)
|
||||||
|
else
|
||||||
|
Post.where(id: ids)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def tagged_post_ids_for(name) =
|
||||||
|
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
|
||||||
|
|
||||||
def sync_post_tags! post, desired_tags
|
def sync_post_tags! post, desired_tags
|
||||||
desired_tags.each do |t|
|
desired_tags.each do |t|
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ class TagsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def autocomplete
|
def autocomplete
|
||||||
q = params[:q].to_s.strip
|
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
|
||||||
return render json: [] if q.blank?
|
|
||||||
|
|
||||||
with_nico = bool?(:nico, default: true)
|
with_nico = bool?(:nico, default: true)
|
||||||
present_only = bool?(:present, default: true)
|
present_only = bool?(:present, default: true)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
include ActiveSupport::Testing::TimeHelpers
|
|
||||||
|
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
require 'set'
|
require 'set'
|
||||||
|
|
||||||
|
|
||||||
RSpec.describe 'Posts API', type: :request do
|
RSpec.describe 'Posts API', type: :request do
|
||||||
# create / update で thumbnail.attach は走るが、
|
# create / update で thumbnail.attach は走るが、
|
||||||
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
|
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
|
||||||
@@ -16,7 +15,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
|
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
|
||||||
let!(:tag) { Tag.create!(tag_name: tag_name, category: 'general') }
|
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
|
||||||
|
|
||||||
let!(:post_record) do
|
let!(:post_record) do
|
||||||
Post.create!(title: 'spec post', url: 'https://example.com/spec').tap do |p|
|
Post.create!(title: 'spec post', url: 'https://example.com/spec').tap do |p|
|
||||||
@@ -28,9 +27,9 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
let!(:tag_name) { TagName.create!(name: "spec_tag") }
|
let!(:tag_name) { TagName.create!(name: "spec_tag") }
|
||||||
let!(:tag) { Tag.create!(tag_name:, category: "general") }
|
let!(:tag) { Tag.create!(tag_name:, category: :general) }
|
||||||
let!(:tag_name2) { TagName.create!(name: 'unko') }
|
let!(:tag_name2) { TagName.create!(name: 'unko') }
|
||||||
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: 'deerjikist') }
|
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
|
||||||
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
|
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
|
||||||
|
|
||||||
let!(:hit_post) do
|
let!(:hit_post) do
|
||||||
@@ -116,201 +115,120 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when url is provided' do
|
context 'when tags contain not:' do
|
||||||
let!(:url_hit_post) do
|
let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') }
|
||||||
Post.create!(uploaded_user: user, title: 'url hit',
|
let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) }
|
||||||
url: 'https://example.com/needle-url-xyz').tap do |p|
|
|
||||||
PostTag.create!(post: p, tag:)
|
let!(:bar_tag_name) { TagName.create!(name: 'not_spec_bar') }
|
||||||
|
let!(:bar_tag) { Tag.create!(tag_name: bar_tag_name, category: :general) }
|
||||||
|
|
||||||
|
let!(:baz_tag_name) { TagName.create!(name: 'not_spec_baz') }
|
||||||
|
let!(:baz_tag) { Tag.create!(tag_name: baz_tag_name, category: :general) }
|
||||||
|
|
||||||
|
let!(:foo_alias_tag_name) do
|
||||||
|
TagName.create!(name: 'not_spec_foo_alias', canonical: foo_tag_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:foo_only_post) do
|
||||||
|
Post.create!(uploaded_user: user, title: 'foo only',
|
||||||
|
url: 'https://example.com/not-spec-foo').tap do |p|
|
||||||
|
PostTag.create!(post: p, tag: foo_tag)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:url_miss_post) do
|
let!(:bar_only_post) do
|
||||||
Post.create!(uploaded_user: user, title: 'url miss',
|
Post.create!(uploaded_user: user, title: 'bar only',
|
||||||
url: 'https://example.com/other-url').tap do |p|
|
url: 'https://example.com/not-spec-bar').tap do |p|
|
||||||
PostTag.create!(post: p, tag:)
|
PostTag.create!(post: p, tag: bar_tag)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'filters posts by url substring' do
|
let!(:baz_only_post) do
|
||||||
get '/posts', params: { url: 'needle-url-xyz' }
|
Post.create!(uploaded_user: user, title: 'baz only',
|
||||||
|
url: 'https://example.com/not-spec-baz').tap do |p|
|
||||||
|
PostTag.create!(post: p, tag: baz_tag)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:foo_bar_post) do
|
||||||
|
Post.create!(uploaded_user: user, title: 'foo bar',
|
||||||
|
url: 'https://example.com/not-spec-foo-bar').tap do |p|
|
||||||
|
PostTag.create!(post: p, tag: foo_tag)
|
||||||
|
PostTag.create!(post: p, tag: bar_tag)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:foo_baz_post) do
|
||||||
|
Post.create!(uploaded_user: user, title: 'foo baz',
|
||||||
|
url: 'https://example.com/not-spec-foo-baz').tap do |p|
|
||||||
|
PostTag.create!(post: p, tag: foo_tag)
|
||||||
|
PostTag.create!(post: p, tag: baz_tag)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:controlled_ids) do
|
||||||
|
[foo_only_post.id, bar_only_post.id, baz_only_post.id,
|
||||||
|
foo_bar_post.id, foo_baz_post.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'supports not search' do
|
||||||
|
get '/posts', params: { tags: 'not:not_spec_foo' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
ids = json.fetch('posts').map { |p| p['id'] }
|
|
||||||
|
|
||||||
expect(ids).to include(url_hit_post.id)
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
||||||
expect(ids).not_to include(url_miss_post.id)
|
[bar_only_post.id, baz_only_post.id]
|
||||||
expect(json.fetch('count')).to eq(1)
|
)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when title is provided' do
|
it 'supports alias in not search' do
|
||||||
let!(:title_hit_post) do
|
get '/posts', params: { tags: 'not:not_spec_foo_alias' }
|
||||||
Post.create!(uploaded_user: user, title: 'needle-title-xyz',
|
|
||||||
url: 'https://example.com/title-hit').tap do |p|
|
|
||||||
PostTag.create!(post: p, tag:)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:title_miss_post) do
|
|
||||||
Post.create!(uploaded_user: user, title: 'other title',
|
|
||||||
url: 'https://example.com/title-miss').tap do |p|
|
|
||||||
PostTag.create!(post: p, tag:)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'filters posts by title substring' do
|
|
||||||
get '/posts', params: { title: 'needle-title-xyz' }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
ids = json.fetch('posts').map { |p| p['id'] }
|
|
||||||
|
|
||||||
expect(ids).to include(title_hit_post.id)
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
||||||
expect(ids).not_to include(title_miss_post.id)
|
[bar_only_post.id, baz_only_post.id]
|
||||||
expect(json.fetch('count')).to eq(1)
|
)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when created_from/created_to are provided' do
|
it 'treats multiple not terms as AND when match is omitted' do
|
||||||
let(:t_created_hit) { Time.zone.local(2010, 1, 5, 12, 0, 0) }
|
get '/posts', params: { tags: 'not:not_spec_foo not:not_spec_bar' }
|
||||||
let(:t_created_miss) { Time.zone.local(2012, 1, 5, 12, 0, 0) }
|
|
||||||
|
|
||||||
let!(:created_hit_post) do
|
|
||||||
travel_to(t_created_hit) do
|
|
||||||
Post.create!(uploaded_user: user, title: 'created hit',
|
|
||||||
url: 'https://example.com/created-hit').tap do |p|
|
|
||||||
PostTag.create!(post: p, tag:)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:created_miss_post) do
|
|
||||||
travel_to(t_created_miss) do
|
|
||||||
Post.create!(uploaded_user: user, title: 'created miss',
|
|
||||||
url: 'https://example.com/created-miss').tap do |p|
|
|
||||||
PostTag.create!(post: p, tag:)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'filters posts by created_at range' do
|
|
||||||
get '/posts', params: {
|
|
||||||
created_from: Time.zone.local(2010, 1, 1, 0, 0, 0).iso8601,
|
|
||||||
created_to: Time.zone.local(2010, 12, 31, 23, 59, 59).iso8601
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
ids = json.fetch('posts').map { |p| p['id'] }
|
|
||||||
|
|
||||||
expect(ids).to include(created_hit_post.id)
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
||||||
expect(ids).not_to include(created_miss_post.id)
|
[baz_only_post.id]
|
||||||
expect(json.fetch('count')).to eq(1)
|
)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when updated_from/updated_to are provided' do
|
it 'treats multiple not terms as OR when match=any' do
|
||||||
let(:t0) { Time.zone.local(2011, 2, 1, 12, 0, 0) }
|
get '/posts', params: { tags: 'not:not_spec_foo not:not_spec_bar', match: 'any' }
|
||||||
let(:t1) { Time.zone.local(2011, 2, 10, 12, 0, 0) }
|
|
||||||
|
|
||||||
let!(:updated_hit_post) do
|
|
||||||
p = nil
|
|
||||||
travel_to(t0) do
|
|
||||||
p = Post.create!(uploaded_user: user, title: 'updated hit',
|
|
||||||
url: 'https://example.com/updated-hit').tap do |pp|
|
|
||||||
PostTag.create!(post: pp, tag:)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
travel_to(t1) do
|
|
||||||
p.update!(title: 'updated hit v2')
|
|
||||||
end
|
|
||||||
p
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:updated_miss_post) do
|
|
||||||
travel_to(Time.zone.local(2013, 1, 1, 12, 0, 0)) do
|
|
||||||
Post.create!(uploaded_user: user, title: 'updated miss',
|
|
||||||
url: 'https://example.com/updated-miss').tap do |p|
|
|
||||||
PostTag.create!(post: p, tag:)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'filters posts by updated_at range' do
|
|
||||||
get '/posts', params: {
|
|
||||||
updated_from: Time.zone.local(2011, 2, 5, 0, 0, 0).iso8601,
|
|
||||||
updated_to: Time.zone.local(2011, 2, 20, 23, 59, 59).iso8601
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
ids = json.fetch('posts').map { |p| p['id'] }
|
|
||||||
|
|
||||||
expect(ids).to include(updated_hit_post.id)
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
||||||
expect(ids).not_to include(updated_miss_post.id)
|
[foo_only_post.id, bar_only_post.id, baz_only_post.id, foo_baz_post.id]
|
||||||
expect(json.fetch('count')).to eq(1)
|
)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when original_created_from/original_created_to are provided' do
|
it 'supports mixed positive and negative search with AND' do
|
||||||
# 注意: controller の現状ロジックに合わせてる
|
get '/posts', params: { tags: 'not_spec_foo not:not_spec_bar' }
|
||||||
# original_created_from は `original_created_before > ?`
|
|
||||||
# original_created_to は `original_created_from <= ?`
|
|
||||||
|
|
||||||
let!(:oc_hit_post) do
|
|
||||||
Post.create!(uploaded_user: user, title: 'oc hit',
|
|
||||||
url: 'https://example.com/oc-hit',
|
|
||||||
original_created_from: Time.zone.local(2015, 1, 1, 0, 0, 0),
|
|
||||||
original_created_before: Time.zone.local(2015, 1, 10, 0, 0, 0)).tap do |p|
|
|
||||||
PostTag.create!(post: p, tag:)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# original_created_from の条件は「original_created_before > param」なので、
|
|
||||||
# before が param 以下になるようにする(ただし before >= from は守る)
|
|
||||||
let!(:oc_miss_post_for_from) do
|
|
||||||
Post.create!(
|
|
||||||
uploaded_user: user,
|
|
||||||
title: 'oc miss for from',
|
|
||||||
url: 'https://example.com/oc-miss-from',
|
|
||||||
original_created_from: Time.zone.local(2014, 12, 1, 0, 0, 0),
|
|
||||||
original_created_before: Time.zone.local(2015, 1, 1, 0, 0, 0)
|
|
||||||
).tap { |p| PostTag.create!(post: p, tag:) }
|
|
||||||
end
|
|
||||||
|
|
||||||
# original_created_to の条件は「original_created_from <= param」なので、
|
|
||||||
# from が param より後になるようにする(before >= from は守る)
|
|
||||||
let!(:oc_miss_post_for_to) do
|
|
||||||
Post.create!(
|
|
||||||
uploaded_user: user,
|
|
||||||
title: 'oc miss for to',
|
|
||||||
url: 'https://example.com/oc-miss-to',
|
|
||||||
original_created_from: Time.zone.local(2015, 2, 1, 0, 0, 0),
|
|
||||||
original_created_before: Time.zone.local(2015, 2, 10, 0, 0, 0)
|
|
||||||
).tap { |p| PostTag.create!(post: p, tag:) }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'filters posts by original_created_from (current controller behavior)' do
|
|
||||||
get '/posts', params: {
|
|
||||||
original_created_from: Time.zone.local(2015, 1, 5, 0, 0, 0).iso8601
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
ids = json.fetch('posts').map { |p| p['id'] }
|
|
||||||
|
|
||||||
expect(ids).to include(oc_hit_post.id)
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
||||||
expect(ids).not_to include(oc_miss_post_for_from.id)
|
[foo_only_post.id, foo_baz_post.id]
|
||||||
expect(json.fetch('count')).to eq(2)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'filters posts by original_created_to (current controller behavior)' do
|
it 'supports mixed positive and negative search with OR when match=any' do
|
||||||
get '/posts', params: {
|
get '/posts', params: { tags: 'not_spec_foo not:not_spec_bar', match: 'any' }
|
||||||
original_created_to: Time.zone.local(2015, 1, 15, 0, 0, 0).iso8601
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
ids = json.fetch('posts').map { |p| p['id'] }
|
|
||||||
|
|
||||||
expect(ids).to include(oc_hit_post.id)
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
||||||
expect(ids).not_to include(oc_miss_post_for_to.id)
|
[foo_only_post.id, baz_only_post.id, foo_bar_post.id, foo_baz_post.id]
|
||||||
expect(json.fetch('count')).to eq(2)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -409,7 +327,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
context "when nico tag already exists in tags" do
|
context "when nico tag already exists in tags" do
|
||||||
before do
|
before do
|
||||||
Tag.find_or_create_by!(tag_name: TagName.find_or_create_by!(name: 'nico:nico_tag'),
|
Tag.find_or_create_by!(tag_name: TagName.find_or_create_by!(name: 'nico:nico_tag'),
|
||||||
category: 'nico')
|
category: :nico)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'return 400' do
|
it 'return 400' do
|
||||||
@@ -475,7 +393,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
|
|
||||||
# 追加で別タグも作って、更新時に入れ替わることを見る
|
# 追加で別タグも作って、更新時に入れ替わることを見る
|
||||||
tn2 = TagName.create!(name: 'spec_tag_2')
|
tn2 = TagName.create!(name: 'spec_tag_2')
|
||||||
Tag.create!(tag_name: tn2, category: 'general')
|
Tag.create!(tag_name: tn2, category: :general)
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: {
|
put "/posts/#{post_record.id}", params: {
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
@@ -494,7 +412,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
context "when nico tag already exists in tags" do
|
context "when nico tag already exists in tags" do
|
||||||
before do
|
before do
|
||||||
Tag.find_or_create_by!(tag_name: TagName.find_or_create_by!(name: 'nico:nico_tag'),
|
Tag.find_or_create_by!(tag_name: TagName.find_or_create_by!(name: 'nico:nico_tag'),
|
||||||
category: 'nico')
|
category: :nico)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'return 400' do
|
it 'return 400' do
|
||||||
@@ -531,7 +449,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
it 'returns add/remove events (history) for a post' do
|
it 'returns add/remove events (history) for a post' do
|
||||||
# add
|
# add
|
||||||
tn2 = TagName.create!(name: 'spec_tag2')
|
tn2 = TagName.create!(name: 'spec_tag2')
|
||||||
tag2 = Tag.create!(tag_name: tn2, category: 'general')
|
tag2 = Tag.create!(tag_name: tn2, category: :general)
|
||||||
pt = PostTag.create!(post: post_record, tag: tag2, created_user: member)
|
pt = PostTag.create!(post: post_record, tag: tag2, created_user: member)
|
||||||
|
|
||||||
# remove (discard)
|
# remove (discard)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import PostDetailPage from '@/pages/posts/PostDetailPage'
|
|||||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||||
import PostListPage from '@/pages/posts/PostListPage'
|
import PostListPage from '@/pages/posts/PostListPage'
|
||||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||||
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 WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||||
@@ -43,7 +42,6 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
<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/nico" element={<NicoTagListPage user={user}/>}/>
|
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// TODO: TagSearch と共通化する.
|
|
||||||
|
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
@@ -83,7 +81,9 @@ export default (({ tags, setTags }: Props) => {
|
|||||||
const pos = (ev.target as HTMLTextAreaElement).selectionStart
|
const pos = (ev.target as HTMLTextAreaElement).selectionStart
|
||||||
await recompute (pos)
|
await recompute (pos)
|
||||||
}}
|
}}
|
||||||
onFocus={() => setFocused (true)}
|
onFocus={() => {
|
||||||
|
setFocused (true)
|
||||||
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setFocused (false)
|
setFocused (false)
|
||||||
setSuggestionsVsbl (false)
|
setSuggestionsVsbl (false)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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 } from '@/consts'
|
||||||
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
|
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
|
||||||
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
|
||||||
|
|
||||||
import type { DragEndEvent } from '@dnd-kit/core'
|
import type { DragEndEvent } from '@dnd-kit/core'
|
||||||
import type { FC, MutableRefObject, ReactNode } from 'react'
|
import type { FC, MutableRefObject, ReactNode } from 'react'
|
||||||
@@ -344,7 +343,7 @@ export default (({ post }: Props) => {
|
|||||||
: 'bot操作'}
|
: 'bot操作'}
|
||||||
</li>
|
</li>
|
||||||
*/}
|
*/}
|
||||||
<li>耕作日時: {dateString (post.createdAt)}</li>
|
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li>
|
||||||
<li>
|
<li>
|
||||||
<>リンク: </>
|
<>リンク: </>
|
||||||
<a
|
<a
|
||||||
@@ -356,14 +355,20 @@ export default (({ post }: Props) => {
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
{/* TODO: 表示形式きしょすぎるので何とかする */}
|
||||||
<>オリジナルの投稿日時: </>
|
<>オリジナルの投稿日時: </>
|
||||||
{originalCreatedAtString (post.originalCreatedFrom,
|
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
|
||||||
post.originalCreatedBefore)}
|
? '不明'
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
{post.originalCreatedFrom
|
||||||
|
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
|
||||||
|
{post.originalCreatedBefore
|
||||||
|
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
|
||||||
|
</>)}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<PrefetchLink to={`/posts/changes?id=${ post.id }`}>
|
<PrefetchLink to={`/posts/changes?id=${ post.id }`}>履歴</PrefetchLink>
|
||||||
履歴
|
|
||||||
</PrefetchLink>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -56,8 +54,11 @@ export default (() => {
|
|||||||
if (activeIndex < 0)
|
if (activeIndex < 0)
|
||||||
break
|
break
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
|
{
|
||||||
const selected = suggestions[activeIndex]
|
const selected = suggestions[activeIndex]
|
||||||
selected && handleTagSelect (selected)
|
if (selected)
|
||||||
|
handleTagSelect (selected)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
@@ -67,14 +68,25 @@ export default (() => {
|
|||||||
}
|
}
|
||||||
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
||||||
{
|
{
|
||||||
navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`)
|
const parts = search.split (' ')
|
||||||
|
const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
|
||||||
|
navigate (`/posts?${
|
||||||
|
(new URLSearchParams ({
|
||||||
|
tags: parts.map (t => t.toLowerCase ())
|
||||||
|
.filter (t => t !== 'type:or')
|
||||||
|
.join (' '),
|
||||||
|
match }))
|
||||||
|
.toString () }`)
|
||||||
setSuggestionsVsbl (false)
|
setSuggestionsVsbl (false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTagSelect = (tag: Tag) => {
|
const handleTagSelect = (tag: Tag) => {
|
||||||
const parts = search.split (' ')
|
const parts = search.split (' ')
|
||||||
parts[parts.length - 1] = tag.name
|
parts[parts.length - 1] = (
|
||||||
|
(parts[parts.length - 1].slice(0, 4).toLowerCase () === 'not:')
|
||||||
|
? ('not:' + tag.name)
|
||||||
|
: tag.name)
|
||||||
setSearch (parts.join (' ') + ' ')
|
setSearch (parts.join (' ') + ' ')
|
||||||
setSuggestions ([])
|
setSuggestions ([])
|
||||||
setActiveIndex (-1)
|
setActiveIndex (-1)
|
||||||
@@ -82,7 +94,8 @@ export default (() => {
|
|||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
const tagsQuery = query.get ('tags') ?? ''
|
const anyFlg = query.get ('match') === 'any'
|
||||||
|
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
|
||||||
setSearch (tagsQuery)
|
setSearch (tagsQuery)
|
||||||
}, [location.search])
|
}, [location.search])
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,8 @@ export default (({ user }: Props) => {
|
|||||||
const menu: Menu = [
|
const menu: Menu = [
|
||||||
{ name: '広場', to: '/posts', subMenu: [
|
{ name: '広場', to: '/posts', subMenu: [
|
||||||
{ name: '一覧', to: '/posts' },
|
{ name: '一覧', to: '/posts' },
|
||||||
{ name: '検索', to: '/posts/search' },
|
|
||||||
{ name: '投稿追加', to: '/posts/new' },
|
{ name: '投稿追加', to: '/posts/new' },
|
||||||
{ 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: false },
|
||||||
|
|||||||
+5
-18
@@ -3,23 +3,13 @@ import type { Category } from 'types'
|
|||||||
export const LIGHT_COLOUR_SHADE = 800
|
export const LIGHT_COLOUR_SHADE = 800
|
||||||
export const DARK_COLOUR_SHADE = 300
|
export const DARK_COLOUR_SHADE = 300
|
||||||
|
|
||||||
export const CATEGORIES = [
|
export const CATEGORIES = ['deerjikist',
|
||||||
'deerjikist',
|
|
||||||
'meme',
|
'meme',
|
||||||
'character',
|
'character',
|
||||||
'general',
|
'general',
|
||||||
'material',
|
'material',
|
||||||
'meta',
|
'meta',
|
||||||
'nico',
|
'nico'] as const
|
||||||
] as const
|
|
||||||
|
|
||||||
export const FETCH_POSTS_ORDER_FIELDS = [
|
|
||||||
'title',
|
|
||||||
'url',
|
|
||||||
'original_created_at',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export const TAG_COLOUR = {
|
export const TAG_COLOUR = {
|
||||||
deerjikist: 'rose',
|
deerjikist: 'rose',
|
||||||
@@ -28,13 +18,10 @@ export const TAG_COLOUR = {
|
|||||||
general: 'cyan',
|
general: 'cyan',
|
||||||
material: 'orange',
|
material: 'orange',
|
||||||
meta: 'yellow',
|
meta: 'yellow',
|
||||||
nico: 'gray',
|
nico: 'gray' } as const satisfies Record<Category, string>
|
||||||
} as const satisfies Record<Category, string>
|
|
||||||
|
|
||||||
export const USER_ROLES = ['admin', 'member', 'guest'] as const
|
export const USER_ROLES = ['admin', 'member', 'guest'] as const
|
||||||
|
|
||||||
export const ViewFlagBehavior = {
|
export const ViewFlagBehavior = { OnShowedDetail: 1,
|
||||||
OnShowedDetail: 1,
|
|
||||||
OnClickedLink: 2,
|
OnClickedLink: 2,
|
||||||
NotAuto: 3,
|
NotAuto: 3 } as const
|
||||||
} as const
|
|
||||||
|
|||||||
+12
-15
@@ -1,28 +1,25 @@
|
|||||||
import { apiDelete, apiGet, apiPost } from '@/lib/api'
|
import { apiDelete, apiGet, apiPost } from '@/lib/api'
|
||||||
|
|
||||||
import type { FetchPostsParams, Post, PostTagChange } from '@/types'
|
import type { Post, PostTagChange } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
export const fetchPosts = async (
|
export const fetchPosts = async (
|
||||||
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
|
{ tags, match, page, limit, cursor }: {
|
||||||
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams
|
tags: string
|
||||||
|
match: 'any' | 'all'
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
cursor?: string }
|
||||||
): Promise<{
|
): Promise<{
|
||||||
posts: Post[]
|
posts: Post[]
|
||||||
count: number }> =>
|
count: number
|
||||||
|
nextCursor: string }> =>
|
||||||
await apiGet ('/posts', { params: {
|
await apiGet ('/posts', { params: {
|
||||||
...(url && { url }),
|
tags,
|
||||||
...(title && { title }),
|
match,
|
||||||
...(tags && { tags }),
|
|
||||||
...(match && { match }),
|
|
||||||
...(createdFrom && { created_from: createdFrom }),
|
|
||||||
...(createdTo && { created_to: createdTo }),
|
|
||||||
...(updatedFrom && { updated_from: updatedFrom }),
|
|
||||||
...(updatedTo && { updated_to: updatedTo }),
|
|
||||||
...(originalCreatedFrom && { original_created_from: originalCreatedFrom }),
|
|
||||||
...(originalCreatedTo && { original_created_to: originalCreatedTo }),
|
|
||||||
...(page && { page }),
|
...(page && { page }),
|
||||||
...(limit && { limit }),
|
...(limit && { limit }),
|
||||||
...(order && { order }) } })
|
...(cursor && { cursor }) } })
|
||||||
|
|
||||||
|
|
||||||
export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`)
|
export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`)
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { fetchWikiPage,
|
|||||||
fetchWikiPageByTitle,
|
fetchWikiPageByTitle,
|
||||||
fetchWikiPages } from '@/lib/wiki'
|
fetchWikiPages } from '@/lib/wiki'
|
||||||
|
|
||||||
import type { FetchPostsOrder } from '@/types'
|
|
||||||
|
|
||||||
type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
|
type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
|
||||||
|
|
||||||
const mPost = match<{ id: string }> ('/posts/:id')
|
const mPost = match<{ id: string }> ('/posts/:id')
|
||||||
@@ -61,20 +59,7 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
|
|||||||
if (version)
|
if (version)
|
||||||
return
|
return
|
||||||
|
|
||||||
const p = {
|
const p = { tags: effectiveTitle, match: 'all', page: 1, limit: 8 } as const
|
||||||
tags: effectiveTitle,
|
|
||||||
match: 'all',
|
|
||||||
page: 1,
|
|
||||||
limit: 8,
|
|
||||||
url: '',
|
|
||||||
title: '',
|
|
||||||
originalCreatedFrom: '',
|
|
||||||
originalCreatedTo: '',
|
|
||||||
createdFrom: '',
|
|
||||||
createdTo: '',
|
|
||||||
updatedFrom: '',
|
|
||||||
updatedTo: '',
|
|
||||||
order: 'original_created_at:desc' } as const
|
|
||||||
await qc.prefetchQuery ({
|
await qc.prefetchQuery ({
|
||||||
queryKey: postsKeys.index (p),
|
queryKey: postsKeys.index (p),
|
||||||
queryFn: () => fetchPosts (p) })
|
queryFn: () => fetchPosts (p) })
|
||||||
@@ -83,27 +68,13 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
|
|||||||
|
|
||||||
const prefetchPostsIndex: Prefetcher = async (qc, url) => {
|
const prefetchPostsIndex: Prefetcher = async (qc, url) => {
|
||||||
const tags = url.searchParams.get ('tags') ?? ''
|
const tags = url.searchParams.get ('tags') ?? ''
|
||||||
const qURL = url.searchParams.get ('url') ?? ''
|
const m = url.searchParams.get ('match') === 'any' ? 'any' : 'all'
|
||||||
const title = url.searchParams.get ('title') ?? ''
|
|
||||||
const originalCreatedFrom = url.searchParams.get ('original_created_from') ?? ''
|
|
||||||
const originalCreatedTo = url.searchParams.get ('original_created_to') ?? ''
|
|
||||||
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 m: 'all' | 'any' = url.searchParams.get ('match') === 'any' ? 'any' : 'all'
|
|
||||||
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)
|
||||||
const order = (url.searchParams.get ('order') ?? 'original_created_at:desc') as FetchPostsOrder
|
|
||||||
|
|
||||||
const keys = {
|
|
||||||
tags, match: m, page, limit, url: qURL, title,
|
|
||||||
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
|
|
||||||
updatedFrom, updatedTo, order }
|
|
||||||
|
|
||||||
await qc.prefetchQuery ({
|
await qc.prefetchQuery ({
|
||||||
queryKey: postsKeys.index (keys),
|
queryKey: postsKeys.index ({ tags, match: m, page, limit }),
|
||||||
queryFn: () => fetchPosts (keys) })
|
queryFn: () => fetchPosts ({ tags, match: m, page, limit }) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -132,9 +103,8 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => {
|
|||||||
|
|
||||||
|
|
||||||
export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [
|
export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [
|
||||||
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
|
{ test: u => u.pathname === '/' || u.pathname === '/posts', run: prefetchPostsIndex },
|
||||||
run: prefetchPostsIndex },
|
{ test: u => (!(['/posts/new', '/posts/changes'].includes (u.pathname))
|
||||||
{ test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname))
|
|
||||||
&& Boolean (mPost (u.pathname))),
|
&& Boolean (mPost (u.pathname))),
|
||||||
run: prefetchPostShow },
|
run: prefetchPostShow },
|
||||||
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
|
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
|
||||||
@@ -149,6 +119,5 @@ export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<
|
|||||||
const r = routePrefetchers.find (x => x.test (u))
|
const r = routePrefetchers.find (x => x.test (u))
|
||||||
if (!(r))
|
if (!(r))
|
||||||
return
|
return
|
||||||
|
|
||||||
await r.run (qc, u)
|
await r.run (qc, u)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { FetchPostsParams } 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: { tags: string; match: 'any' | 'all'; page: number; limit: number }) =>
|
||||||
|
['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; page: number; limit: number }) =>
|
||||||
|
|||||||
@@ -4,23 +4,4 @@ import { twMerge } from 'tailwind-merge'
|
|||||||
import type { ClassValue } from 'clsx'
|
import type { ClassValue } from 'clsx'
|
||||||
|
|
||||||
|
|
||||||
export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Date (d) : d
|
|
||||||
|
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
|
|
||||||
// TODO: 表示形式きしょすぎるので何とかする
|
|
||||||
export const originalCreatedAtString = (
|
|
||||||
f: string | Date | null,
|
|
||||||
b: string | Date | null,
|
|
||||||
): string =>
|
|
||||||
([f ? `${ dateString (f) } 以降` : '',
|
|
||||||
b ? `${ dateString (b) } より前` : '']
|
|
||||||
.filter (Boolean)
|
|
||||||
.join (' '))
|
|
||||||
|| '不明'
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -12,7 +10,7 @@ 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 } from '@/lib/queryKeys'
|
||||||
import { cn, dateString } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -33,10 +31,6 @@ export default (() => {
|
|||||||
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
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
|
||||||
}, [location.search])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -78,17 +72,10 @@ export default (() => {
|
|||||||
<td className="align-top p-2 bg-white dark:bg-[#242424] border-r"
|
<td className="align-top p-2 bg-white dark:bg-[#242424] border-r"
|
||||||
rowSpan={rowsCnt}>
|
rowSpan={rowsCnt}>
|
||||||
<PrefetchLink to={`/posts/${ change.post.id }`}>
|
<PrefetchLink to={`/posts/${ change.post.id }`}>
|
||||||
<motion.div
|
|
||||||
layoutId={`page-${ change.post.id }`}
|
|
||||||
transition={{ type: 'spring',
|
|
||||||
stiffness: 500,
|
|
||||||
damping: 40,
|
|
||||||
mass: .5 }}>
|
|
||||||
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
|
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
|
||||||
alt={change.post.title || change.post.url}
|
alt={change.post.title || change.post.url}
|
||||||
title={change.post.title || change.post.url || undefined}
|
title={change.post.title || change.post.url || undefined}
|
||||||
className="w-40"/>
|
className="w-40"/>
|
||||||
</motion.div>
|
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</td>)}
|
</td>)}
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
@@ -96,14 +83,12 @@ export default (() => {
|
|||||||
{`を${ change.changeType === 'add' ? '記載' : '消除' }`}
|
{`を${ change.changeType === 'add' ? '記載' : '消除' }`}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{change.user
|
{change.user ? (
|
||||||
? (
|
|
||||||
<PrefetchLink to={`/users/${ change.user.id }`}>
|
<PrefetchLink to={`/users/${ change.user.id }`}>
|
||||||
{change.user.name}
|
{change.user.name}
|
||||||
</PrefetchLink>)
|
</PrefetchLink>) : 'bot 操作'}
|
||||||
: 'bot 操作'}
|
|
||||||
<br/>
|
<br/>
|
||||||
{dateString (change.timestamp)}
|
{change.timestamp}
|
||||||
</td>
|
</td>
|
||||||
</tr>)
|
</tr>)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -35,16 +35,11 @@ export default (() => {
|
|||||||
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)
|
||||||
|
|
||||||
const keys = {
|
|
||||||
tags: tagsKey, match, page, limit,
|
|
||||||
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
|
|
||||||
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
|
||||||
order: 'original_created_at:desc' } as const
|
|
||||||
const { data, isLoading: loading } = useQuery ({
|
const { data, isLoading: loading } = useQuery ({
|
||||||
queryKey: postsKeys.index (keys),
|
queryKey: postsKeys.index ({ tags: tagsKey, match, page, limit }),
|
||||||
queryFn: () => fetchPosts (keys) })
|
queryFn: () => fetchPosts ({ tags: tagsKey, match, page, limit }) })
|
||||||
const posts = data?.posts ?? []
|
const posts = data?.posts ?? []
|
||||||
const cursor = ''
|
const cursor = data?.nextCursor ?? ''
|
||||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||||
|
|
||||||
useLayoutEffect (() => {
|
useLayoutEffect (() => {
|
||||||
|
|||||||
@@ -1,410 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
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 TagLink from '@/components/TagLink'
|
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
|
||||||
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 { apiGet } from '@/lib/api'
|
|
||||||
import { fetchPosts } from '@/lib/posts'
|
|
||||||
import { postsKeys } from '@/lib/queryKeys'
|
|
||||||
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
|
||||||
|
|
||||||
import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
|
|
||||||
|
|
||||||
import type { FetchPostsOrder,
|
|
||||||
FetchPostsOrderField,
|
|
||||||
FetchPostsParams,
|
|
||||||
Tag } 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 qURL = query.get ('url') ?? ''
|
|
||||||
const qTitle = query.get ('title') ?? ''
|
|
||||||
const qTags = query.get ('tags') ?? ''
|
|
||||||
const qMatch: 'all' | 'any' = query.get ('match') === 'any' ? 'any' : 'all'
|
|
||||||
const qOriginalCreatedFrom = query.get ('original_created_from') ?? ''
|
|
||||||
const qOriginalCreatedTo = query.get ('original_created_to') ?? ''
|
|
||||||
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') || 'original_created_at:desc') as FetchPostsOrder
|
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
|
||||||
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
|
|
||||||
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
|
||||||
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
|
|
||||||
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
|
||||||
const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
|
|
||||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
|
||||||
const [tagsStr, setTagsStr] = useState ('')
|
|
||||||
const [title, setTitle] = useState ('')
|
|
||||||
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
|
||||||
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
|
|
||||||
const [url, setURL] = useState ('')
|
|
||||||
|
|
||||||
const keys: FetchPostsParams = {
|
|
||||||
tags: qTags, match: qMatch, page, limit,
|
|
||||||
url: qURL,
|
|
||||||
title: qTitle,
|
|
||||||
originalCreatedFrom: qOriginalCreatedFrom,
|
|
||||||
originalCreatedTo: qOriginalCreatedTo,
|
|
||||||
createdFrom: qCreatedFrom,
|
|
||||||
createdTo: qCreatedTo,
|
|
||||||
updatedFrom: qUpdatedFrom,
|
|
||||||
updatedTo: qUpdatedTo,
|
|
||||||
order }
|
|
||||||
const { data, isLoading: loading } = useQuery ({
|
|
||||||
queryKey: postsKeys.index (keys),
|
|
||||||
queryFn: () => fetchPosts (keys) })
|
|
||||||
const results = data?.posts ?? []
|
|
||||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
setURL (qURL ?? '')
|
|
||||||
setTitle (qTitle ?? '')
|
|
||||||
setTagsStr (qTags ?? '')
|
|
||||||
setMatchType (qMatch ?? 'all')
|
|
||||||
setOriginalCreatedFrom (qOriginalCreatedFrom)
|
|
||||||
setOriginalCreatedTo (qOriginalCreatedTo)
|
|
||||||
setCreatedFrom (qCreatedFrom)
|
|
||||||
setCreatedTo (qCreatedTo)
|
|
||||||
setUpdatedFrom (qUpdatedFrom)
|
|
||||||
setUpdatedTo (qUpdatedTo)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
const q = ev.target.value.trim ().split (' ').at (-1)
|
|
||||||
if (!(q))
|
|
||||||
{
|
|
||||||
setSuggestions ([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
|
|
||||||
setSuggestions (data.filter (t => t.postCount > 0))
|
|
||||||
if (suggestions.length > 0)
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
switch (ev.key)
|
|
||||||
{
|
|
||||||
case 'ArrowDown':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'ArrowUp':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setActiveIndex (i => Math.max (i - 1, -1))
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'Enter':
|
|
||||||
if (activeIndex < 0)
|
|
||||||
break
|
|
||||||
ev.preventDefault ()
|
|
||||||
const selected = suggestions[activeIndex]
|
|
||||||
selected && handleTagSelect (selected)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'Escape':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
|
||||||
{
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = async () => {
|
|
||||||
const qs = new URLSearchParams ()
|
|
||||||
setIf (qs, 'tags', tagsStr)
|
|
||||||
setIf (qs, 'url', url)
|
|
||||||
setIf (qs, 'title', title)
|
|
||||||
setIf (qs, 'original_created_from', originalCreatedFrom)
|
|
||||||
setIf (qs, 'original_created_to', originalCreatedTo)
|
|
||||||
setIf (qs, 'created_from', createdFrom)
|
|
||||||
setIf (qs, 'created_to', createdTo)
|
|
||||||
setIf (qs, 'updated_from', updatedFrom)
|
|
||||||
setIf (qs, 'updated_to', updatedTo)
|
|
||||||
qs.set ('match', matchType)
|
|
||||||
qs.set ('page', String ('1'))
|
|
||||||
qs.set ('order', order)
|
|
||||||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
|
||||||
const handleTagSelect = (tag: Tag) => {
|
|
||||||
const parts = tagsStr.split (' ')
|
|
||||||
parts[parts.length - 1] = tag.name
|
|
||||||
setTagsStr (parts.join (' ') + ' ')
|
|
||||||
setSuggestions ([])
|
|
||||||
setActiveIndex (-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
|
||||||
e.preventDefault ()
|
|
||||||
search ()
|
|
||||||
}
|
|
||||||
|
|
||||||
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={title}
|
|
||||||
onChange={e => setTitle (e.target.value)}
|
|
||||||
className="w-full border p-2 rounded"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* URL */}
|
|
||||||
<div>
|
|
||||||
<Label>URL</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={url}
|
|
||||||
onChange={e => setURL (e.target.value)}
|
|
||||||
className="w-full border p-2 rounded"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* タグ */}
|
|
||||||
<div className="relative">
|
|
||||||
<Label>タグ</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tagsStr}
|
|
||||||
onChange={whenChanged}
|
|
||||||
onFocus={() => setSuggestionsVsbl (true)}
|
|
||||||
onBlur={() => setSuggestionsVsbl (false)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="w-full border p-2 rounded"/>
|
|
||||||
<TagSearchBox
|
|
||||||
suggestions={
|
|
||||||
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
|
|
||||||
activeIndex={activeIndex}
|
|
||||||
onSelect={handleTagSelect}/>
|
|
||||||
<fieldset className="w-full my-2">
|
|
||||||
<label>検索区分:</label>
|
|
||||||
<label className="mx-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="match-type"
|
|
||||||
checked={matchType === 'all'}
|
|
||||||
onChange={() => setMatchType ('all')}/>
|
|
||||||
AND
|
|
||||||
</label>
|
|
||||||
<label className="mx-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="match-type"
|
|
||||||
checked={matchType === 'any'}
|
|
||||||
onChange={() => setMatchType ('any')}/>
|
|
||||||
OR
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* オリジナルの投稿日時 */}
|
|
||||||
<div>
|
|
||||||
<Label>オリジナルの投稿日時</Label>
|
|
||||||
<DateTimeField
|
|
||||||
value={originalCreatedFrom ?? undefined}
|
|
||||||
onChange={setOriginalCreatedFrom}/>
|
|
||||||
<span className="mx-1">〜</span>
|
|
||||||
<DateTimeField
|
|
||||||
value={originalCreatedTo ?? undefined}
|
|
||||||
onChange={setOriginalCreatedTo}/>
|
|
||||||
</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-14"/>
|
|
||||||
<col className="w-72"/>
|
|
||||||
<col className="w-80"/>
|
|
||||||
<col className="w-[24rem]"/>
|
|
||||||
<col className="w-60"/>
|
|
||||||
<col className="w-44"/>
|
|
||||||
<col className="w-44"/>
|
|
||||||
</colgroup>
|
|
||||||
|
|
||||||
<thead className="border-b-2 border-black dark:border-white">
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 text-left whitespace-nowrap">投稿</th>
|
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
|
||||||
<SortHeader by="title" label="タイトル"/>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
|
||||||
<SortHeader by="url" label="URL"/>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-left whitespace-nowrap">タグ</th>
|
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
|
||||||
<SortHeader by="original_created_at" label="オリジナルの投稿日時"/>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
|
||||||
<SortHeader by="created_at" label="投稿日時"/>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
|
||||||
<SortHeader by="updated_at" label="更新日時"/>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{results.map (row => (
|
|
||||||
<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
|
|
||||||
layoutId={`page-${ row.id }`}
|
|
||||||
transition={{ type: 'spring',
|
|
||||||
stiffness: 500,
|
|
||||||
damping: 40,
|
|
||||||
mass: .5 }}>
|
|
||||||
<img src={row.thumbnail || row.thumbnailBase || undefined}
|
|
||||||
alt={row.title || row.url}
|
|
||||||
title={row.title || row.url || undefined}
|
|
||||||
className="w-8"/>
|
|
||||||
</motion.div>
|
|
||||||
</PrefetchLink>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 truncate">
|
|
||||||
<PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
|
|
||||||
{row.title}
|
|
||||||
</PrefetchLink>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 truncate">
|
|
||||||
<a href={row.url}
|
|
||||||
title={row.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer nofollow">
|
|
||||||
{row.url}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td className="p-2">
|
|
||||||
{row.tags.map (t => (
|
|
||||||
<span key={t.id} className="mr-2">
|
|
||||||
<TagLink tag={t} withWiki={false} withCount={false}/>
|
|
||||||
</span>))}
|
|
||||||
</td>
|
|
||||||
<td className="p-2">
|
|
||||||
{originalCreatedAtString (row.originalCreatedFrom,
|
|
||||||
row.originalCreatedBefore)}
|
|
||||||
</td>
|
|
||||||
<td className="p-2">{dateString (row.createdAt)}</td>
|
|
||||||
<td className="p-2">{dateString (row.updatedAt)}</td>
|
|
||||||
</tr>))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Pagination page={page} totalPages={totalPages}/>
|
|
||||||
</div>) : '結果ないよ(笑)')}
|
|
||||||
</MainArea>)
|
|
||||||
}) satisfies FC
|
|
||||||
@@ -44,15 +44,10 @@ export default () => {
|
|||||||
queryKey: tagsKeys.show (effectiveTitle),
|
queryKey: tagsKeys.show (effectiveTitle),
|
||||||
queryFn: () => fetchTagByName (effectiveTitle) })
|
queryFn: () => fetchTagByName (effectiveTitle) })
|
||||||
|
|
||||||
const keys = {
|
|
||||||
tags: effectiveTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
|
|
||||||
originalCreatedFrom: '', originalCreatedTo: '',
|
|
||||||
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
|
||||||
order: 'original_created_at:desc' } as const
|
|
||||||
const { data } = useQuery ({
|
const { data } = useQuery ({
|
||||||
enabled: Boolean (effectiveTitle) && !(version),
|
enabled: Boolean (effectiveTitle) && !(version),
|
||||||
queryKey: postsKeys.index (keys),
|
queryKey: postsKeys.index ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }),
|
||||||
queryFn: () => fetchPosts (keys) })
|
queryFn: () => fetchPosts ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }) })
|
||||||
const posts = data?.posts || []
|
const posts = data?.posts || []
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import PageTitle from '@/components/common/PageTitle'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
import { dateString } from '@/lib/utils'
|
|
||||||
|
|
||||||
import type { WikiPageChange } from '@/types'
|
import type { WikiPageChange } from '@/types'
|
||||||
|
|
||||||
@@ -66,7 +65,7 @@ export default () => {
|
|||||||
{change.user.name}
|
{change.user.name}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<br/>
|
<br/>
|
||||||
{dateString (change.timestamp)}
|
{change.timestamp}
|
||||||
</td>
|
</td>
|
||||||
</tr>))}
|
</tr>))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import PageTitle from '@/components/common/PageTitle'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
import { dateString } from '@/lib/utils'
|
|
||||||
|
|
||||||
import type { FormEvent } from 'react'
|
import type { FormEvent } from 'react'
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ export default () => {
|
|||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{dateString (page.updatedAt)}
|
{page.updatedAt}
|
||||||
</td>
|
</td>
|
||||||
</tr>))}
|
</tr>))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
+3
-26
@@ -1,31 +1,9 @@
|
|||||||
import { CATEGORIES,
|
import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts'
|
||||||
FETCH_POSTS_ORDER_FIELDS,
|
|
||||||
USER_ROLES,
|
|
||||||
ViewFlagBehavior } from '@/consts'
|
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export type Category = typeof CATEGORIES[number]
|
export type Category = typeof CATEGORIES[number]
|
||||||
|
|
||||||
export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }`
|
|
||||||
|
|
||||||
export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number]
|
|
||||||
|
|
||||||
export type FetchPostsParams = {
|
|
||||||
url: string
|
|
||||||
title: string
|
|
||||||
tags: string
|
|
||||||
match: 'all' | 'any'
|
|
||||||
originalCreatedFrom: string
|
|
||||||
originalCreatedTo: string
|
|
||||||
createdFrom: string
|
|
||||||
createdTo: string
|
|
||||||
updatedFrom: string
|
|
||||||
updatedTo: string
|
|
||||||
page: number
|
|
||||||
limit: number
|
|
||||||
order: FetchPostsOrder }
|
|
||||||
|
|
||||||
export type Menu = MenuItem[]
|
export type Menu = MenuItem[]
|
||||||
|
|
||||||
export type MenuItem = {
|
export type MenuItem = {
|
||||||
@@ -47,10 +25,9 @@ export type Post = {
|
|||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
viewed: boolean
|
viewed: boolean
|
||||||
related: Post[]
|
related: Post[]
|
||||||
originalCreatedFrom: string | null
|
|
||||||
originalCreatedBefore: string | null
|
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string }
|
originalCreatedFrom: string | null
|
||||||
|
originalCreatedBefore: string | null }
|
||||||
|
|
||||||
export type PostTagChange = {
|
export type PostTagChange = {
|
||||||
post: Post
|
post: Post
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする