コミットを比較
5 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| c3aa8bc580 | |||
| 663206c14c | |||
| 3e5eb4687b | |||
| 2a412b589f | |||
| 498f215538 |
@@ -2,38 +2,46 @@ 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
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
sort_sql =
|
|
||||||
'COALESCE(posts.original_created_before - INTERVAL 1 SECOND,' +
|
|
||||||
'posts.original_created_from,' +
|
|
||||||
'posts.created_at)'
|
|
||||||
q =
|
q =
|
||||||
filtered_posts
|
filtered_posts
|
||||||
.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")
|
|
||||||
.order(Arel.sql("#{ sort_sql } DESC"))
|
|
||||||
posts =
|
|
||||||
if cursor
|
|
||||||
q.where("#{ sort_sql } < ?", Time.iso8601(cursor)).limit(limit + 1)
|
|
||||||
else
|
|
||||||
q.limit(limit).offset(offset)
|
|
||||||
end
|
|
||||||
.to_a
|
|
||||||
|
|
||||||
next_cursor = nil
|
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
||||||
if cursor && posts.length > limit
|
q = q.where('posts.title LIKE ?', "%#{ title }%") if title
|
||||||
next_cursor = posts.last.read_attribute('sort_ts').iso8601(6)
|
if original_created_from
|
||||||
posts = posts.first(limit)
|
q = q.where('posts.original_created_before > ?', original_created_from)
|
||||||
end
|
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]
|
||||||
|
q = q.where('posts.updated_at >= ?', updated_between[0]) if updated_between[0]
|
||||||
|
q = q.where('posts.updated_at <= ?', updated_between[1]) if updated_between[1]
|
||||||
|
|
||||||
|
sort_sql =
|
||||||
|
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE,' +
|
||||||
|
'posts.original_created_from,' +
|
||||||
|
'posts.created_at)'
|
||||||
|
posts = q.select("posts.*, #{ sort_sql } AS sort_ts")
|
||||||
|
.order(Arel.sql("#{ sort_sql } DESC"))
|
||||||
|
.limit(limit).offset(offset).to_a
|
||||||
|
|
||||||
render json: { posts: posts.map { |post|
|
render json: { posts: posts.map { |post|
|
||||||
PostRepr.base(post).tap do |json|
|
PostRepr.base(post).tap do |json|
|
||||||
@@ -44,11 +52,7 @@ class PostsController < ApplicationController
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
}, count: if filtered_posts.group_values.present?
|
}, count: q.group_values.present? ? q.count.size : q.count }
|
||||||
filtered_posts.count.size
|
|
||||||
else
|
|
||||||
filtered_posts.count
|
|
||||||
end, next_cursor: }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def random
|
def random
|
||||||
@@ -63,7 +67,7 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
post = Post.includes(tags: { tag_name: :wiki_page }).find(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
|
viewed = current_user&.viewed?(post) || false
|
||||||
@@ -84,7 +88,7 @@ class PostsController < ApplicationController
|
|||||||
title = params[:title].presence
|
title = params[:title].presence
|
||||||
url = params[:url]
|
url = params[:url]
|
||||||
thumbnail = params[:thumbnail]
|
thumbnail = params[:thumbnail]
|
||||||
tag_names = params[:tags].to_s.split(' ')
|
tag_names = params[:tags].to_s.split
|
||||||
original_created_from = params[:original_created_from]
|
original_created_from = params[:original_created_from]
|
||||||
original_created_before = params[:original_created_before]
|
original_created_before = params[:original_created_before]
|
||||||
|
|
||||||
@@ -125,7 +129,7 @@ class PostsController < ApplicationController
|
|||||||
return head :forbidden unless current_user.member?
|
return head :forbidden unless current_user.member?
|
||||||
|
|
||||||
title = params[:title].presence
|
title = params[:title].presence
|
||||||
tag_names = params[:tags].to_s.split(' ')
|
tag_names = params[:tags].to_s.split
|
||||||
original_created_from = params[:original_created_from]
|
original_created_from = params[:original_created_from]
|
||||||
original_created_before = params[:original_created_before]
|
original_created_before = params[:original_created_before]
|
||||||
|
|
||||||
@@ -192,7 +196,7 @@ class PostsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def filtered_posts
|
def filtered_posts
|
||||||
tag_names = params[:tags].to_s.split(' ')
|
tag_names = params[:tags].to_s.split
|
||||||
match_type = params[:match]
|
match_type = params[:match]
|
||||||
if tag_names.present?
|
if tag_names.present?
|
||||||
filter_posts_by_tags(tag_names, match_type)
|
filter_posts_by_tags(tag_names, match_type)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
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 ではスタブしとくのが無難。
|
||||||
@@ -114,6 +115,204 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(json.fetch('count')).to eq(0)
|
expect(json.fetch('count')).to eq(0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when url is provided' do
|
||||||
|
let!(:url_hit_post) do
|
||||||
|
Post.create!(uploaded_user: user, title: 'url hit',
|
||||||
|
url: 'https://example.com/needle-url-xyz').tap do |p|
|
||||||
|
PostTag.create!(post: p, tag:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:url_miss_post) do
|
||||||
|
Post.create!(uploaded_user: user, title: 'url miss',
|
||||||
|
url: 'https://example.com/other-url').tap do |p|
|
||||||
|
PostTag.create!(post: p, tag:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filters posts by url substring' do
|
||||||
|
get '/posts', params: { url: 'needle-url-xyz' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
ids = json.fetch('posts').map { |p| p['id'] }
|
||||||
|
|
||||||
|
expect(ids).to include(url_hit_post.id)
|
||||||
|
expect(ids).not_to include(url_miss_post.id)
|
||||||
|
expect(json.fetch('count')).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when title is provided' do
|
||||||
|
let!(:title_hit_post) do
|
||||||
|
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)
|
||||||
|
ids = json.fetch('posts').map { |p| p['id'] }
|
||||||
|
|
||||||
|
expect(ids).to include(title_hit_post.id)
|
||||||
|
expect(ids).not_to include(title_miss_post.id)
|
||||||
|
expect(json.fetch('count')).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when created_from/created_to are provided' do
|
||||||
|
let(:t_created_hit) { Time.zone.local(2010, 1, 5, 12, 0, 0) }
|
||||||
|
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)
|
||||||
|
ids = json.fetch('posts').map { |p| p['id'] }
|
||||||
|
|
||||||
|
expect(ids).to include(created_hit_post.id)
|
||||||
|
expect(ids).not_to include(created_miss_post.id)
|
||||||
|
expect(json.fetch('count')).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when updated_from/updated_to are provided' do
|
||||||
|
let(:t0) { Time.zone.local(2011, 2, 1, 12, 0, 0) }
|
||||||
|
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)
|
||||||
|
ids = json.fetch('posts').map { |p| p['id'] }
|
||||||
|
|
||||||
|
expect(ids).to include(updated_hit_post.id)
|
||||||
|
expect(ids).not_to include(updated_miss_post.id)
|
||||||
|
expect(json.fetch('count')).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when original_created_from/original_created_to are provided' do
|
||||||
|
# 注意: controller の現状ロジックに合わせてる
|
||||||
|
# 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)
|
||||||
|
ids = json.fetch('posts').map { |p| p['id'] }
|
||||||
|
|
||||||
|
expect(ids).to include(oc_hit_post.id)
|
||||||
|
expect(ids).not_to include(oc_miss_post_for_from.id)
|
||||||
|
expect(json.fetch('count')).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filters posts by original_created_to (current controller behavior)' do
|
||||||
|
get '/posts', params: {
|
||||||
|
original_created_to: Time.zone.local(2015, 1, 15, 0, 0, 0).iso8601
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
ids = json.fetch('posts').map { |p| p['id'] }
|
||||||
|
|
||||||
|
expect(ids).to include(oc_hit_post.id)
|
||||||
|
expect(ids).not_to include(oc_miss_post_for_to.id)
|
||||||
|
expect(json.fetch('count')).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /posts/:id' do
|
describe 'GET /posts/:id' do
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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'
|
||||||
@@ -42,6 +43,7 @@ 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}/>}/>
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ 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 },
|
||||||
|
|||||||
+18
-12
@@ -4,22 +4,28 @@ import type { Post, PostTagChange } from '@/types'
|
|||||||
|
|
||||||
|
|
||||||
export const fetchPosts = async (
|
export const fetchPosts = async (
|
||||||
{ tags, match, page, limit, cursor }: {
|
{ url, title, tags, match, created_from, created_to, updated_from,
|
||||||
tags: string
|
updated_to, original_created_from, original_created_to, page, limit }: {
|
||||||
match: 'any' | 'all'
|
url?: string
|
||||||
page?: number
|
title?: string
|
||||||
limit?: number
|
tags?: string
|
||||||
cursor?: string }
|
match?: 'all' | 'any'
|
||||||
|
created_from?: string
|
||||||
|
created_to?: string
|
||||||
|
updated_from?: string
|
||||||
|
updated_to?: string
|
||||||
|
original_created_from?: string
|
||||||
|
original_created_to?: string
|
||||||
|
page?: number
|
||||||
|
limit?: number },
|
||||||
): Promise<{
|
): Promise<{
|
||||||
posts: Post[]
|
posts: Post[]
|
||||||
count: number
|
count: number }> =>
|
||||||
nextCursor: string }> =>
|
|
||||||
await apiGet ('/posts', { params: {
|
await apiGet ('/posts', { params: {
|
||||||
tags,
|
url, title, tags, match, created_from, created_to, updated_from, updated_to,
|
||||||
match,
|
original_created_from, original_created_to,
|
||||||
...(page && { page }),
|
...(page && { page }),
|
||||||
...(limit && { limit }),
|
...(limit && { limit }) } })
|
||||||
...(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 }`)
|
||||||
|
|||||||
@@ -68,13 +68,32 @@ 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 m = url.searchParams.get ('match') === 'any' ? 'any' : 'all'
|
const qURL = url.searchParams.get ('url')
|
||||||
|
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 keys = {
|
||||||
|
tags, match: m, page, limit,
|
||||||
|
...(qURL && { url: qURL }),
|
||||||
|
...(title && { title }),
|
||||||
|
...(originalCreatedFrom && { original_created_from: originalCreatedFrom }),
|
||||||
|
...(originalCreatedTo && { original_created_to: originalCreatedTo }),
|
||||||
|
...(createdFrom && { created_from: createdFrom }),
|
||||||
|
...(createdTo && { created_to: createdTo }),
|
||||||
|
...(updatedFrom && { updated_from: updatedFrom }),
|
||||||
|
...(updatedTo && { updated_to: updatedTo }) }
|
||||||
|
|
||||||
await qc.prefetchQuery ({
|
await qc.prefetchQuery ({
|
||||||
queryKey: postsKeys.index ({ tags, match: m, page, limit }),
|
queryKey: postsKeys.index (keys),
|
||||||
queryFn: () => fetchPosts ({ tags, match: m, page, limit }) })
|
queryFn: () => fetchPosts (keys) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -103,8 +122,9 @@ 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 => u.pathname === '/' || u.pathname === '/posts', run: prefetchPostsIndex },
|
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
|
||||||
{ test: u => (!(['/posts/new', '/posts/changes'].includes (u.pathname))
|
run: prefetchPostsIndex },
|
||||||
|
{ 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 },
|
||||||
@@ -119,5 +139,6 @@ 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,7 +1,17 @@
|
|||||||
export const postsKeys = {
|
export const postsKeys = {
|
||||||
root: ['posts'] as const,
|
root: ['posts'] as const,
|
||||||
index: (p: { tags: string; match: 'any' | 'all'; page: number; limit: number }) =>
|
index: (p: { url?: string
|
||||||
['posts', 'index', p] as const,
|
title?: string
|
||||||
|
tags?: string
|
||||||
|
match?: 'all' | 'any'
|
||||||
|
created_from?: string
|
||||||
|
created_to?: string
|
||||||
|
updated_from?: string
|
||||||
|
updated_to?: string
|
||||||
|
original_created_from?: string
|
||||||
|
original_created_to?: string
|
||||||
|
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 }) =>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default (() => {
|
|||||||
queryKey: postsKeys.index ({ tags: tagsKey, match, page, limit }),
|
queryKey: postsKeys.index ({ tags: tagsKey, match, page, limit }),
|
||||||
queryFn: () => fetchPosts ({ tags: tagsKey, match, page, limit }) })
|
queryFn: () => fetchPosts ({ tags: tagsKey, match, page, limit }) })
|
||||||
const posts = data?.posts ?? []
|
const posts = data?.posts ?? []
|
||||||
const cursor = data?.nextCursor ?? ''
|
const cursor = ''
|
||||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||||
|
|
||||||
useLayoutEffect (() => {
|
useLayoutEffect (() => {
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { 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 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 { fetchPosts } from '@/lib/posts'
|
||||||
|
import { postsKeys } from '@/lib/queryKeys'
|
||||||
|
|
||||||
|
import type { FC, FormEvent } from 'react'
|
||||||
|
|
||||||
|
|
||||||
|
export default (() => {
|
||||||
|
const navigate = useNavigate ()
|
||||||
|
|
||||||
|
const location = useLocation ()
|
||||||
|
const query = new URLSearchParams (location.search)
|
||||||
|
const page = Number (query.get ('page') ?? 1)
|
||||||
|
const limit = Number (query.get ('limit') ?? 20)
|
||||||
|
|
||||||
|
const [createdFrom, setCreatedFrom] =
|
||||||
|
useState<string | null> (query.get ('created_from'))
|
||||||
|
const [createdTo, setCreatedTo] =
|
||||||
|
useState<string | null> (query.get ('created_to'))
|
||||||
|
const [matchType, setMatchType] =
|
||||||
|
useState<'all' | 'any'> ((query.get ('match') as 'all' | 'any' | null) ?? 'all')
|
||||||
|
const [originalCreatedFrom, setOriginalCreatedFrom] =
|
||||||
|
useState<string | null> (query.get ('original_created_from'))
|
||||||
|
const [originalCreatedTo, setOriginalCreatedTo] =
|
||||||
|
useState<string | null> (query.get ('original_created_to'))
|
||||||
|
const [tagsStr, setTagsStr] = useState (query.get ('tags') ?? '')
|
||||||
|
const [title, setTitle] = useState (query.get ('title') ?? '')
|
||||||
|
const [updatedFrom, setUpdatedFrom] =
|
||||||
|
useState<string | null> (query.get ('updated_from'))
|
||||||
|
const [updatedTo, setUpdatedTo] =
|
||||||
|
useState<string | null> (query.get ('updated_to'))
|
||||||
|
const [url, setURL] = useState (query.get ('url') ?? '')
|
||||||
|
|
||||||
|
const keys = {
|
||||||
|
tags: tagsStr, match: matchType, page, limit,
|
||||||
|
...(url && { url }),
|
||||||
|
...(title && { title }),
|
||||||
|
...(originalCreatedFrom && { original_created_from: originalCreatedFrom }),
|
||||||
|
...(originalCreatedTo && { original_created_to: originalCreatedTo }),
|
||||||
|
...(createdFrom && { created_from: createdFrom }),
|
||||||
|
...(createdTo && { created_to: createdTo }),
|
||||||
|
...(updatedFrom && { updated_from: updatedFrom }),
|
||||||
|
...(updatedTo && { updated_to: updatedTo }) }
|
||||||
|
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
|
||||||
|
|
||||||
|
const search = async () => {
|
||||||
|
const qs = new URLSearchParams (location.search)
|
||||||
|
qs.set ('page', String ('1'))
|
||||||
|
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Label>タグ</Label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagsStr}
|
||||||
|
onChange={e => setTagsStr (e.target.value)}
|
||||||
|
className="w-full border p-2 rounded"/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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-44"/>
|
||||||
|
<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">タイトル</th>
|
||||||
|
<th className="p-2 text-left whitespace-nowrap">URL</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>
|
||||||
|
<th className="p-2 text-left whitespace-nowrap">更新日時</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}>
|
||||||
|
<img src={row.thumbnail || row.thumbnailBase || undefined}
|
||||||
|
alt={row.title || row.url}
|
||||||
|
title={row.title || row.url || undefined}
|
||||||
|
className="w-8"/>
|
||||||
|
</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">
|
||||||
|
{row.originalCreatedFrom} 〜 {row.originalCreatedBefore}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">{row.createdAt}</td>
|
||||||
|
<td className="p-2">{row.updatedAt}</td>
|
||||||
|
</tr>))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination page={page} totalPages={totalPages}/>
|
||||||
|
</div>)}
|
||||||
|
</MainArea>)
|
||||||
|
}) satisfies FC
|
||||||
+3
-2
@@ -25,9 +25,10 @@ export type Post = {
|
|||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
viewed: boolean
|
viewed: boolean
|
||||||
related: Post[]
|
related: Post[]
|
||||||
createdAt: string
|
|
||||||
originalCreatedFrom: string | null
|
originalCreatedFrom: string | null
|
||||||
originalCreatedBefore: string | null }
|
originalCreatedBefore: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string }
|
||||||
|
|
||||||
export type PostTagChange = {
|
export type PostTagChange = {
|
||||||
post: Post
|
post: Post
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする