From 498f215538ed25c7bcb546b7e83ebb40d7a2ef74 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 23 Feb 2026 18:52:09 +0900 Subject: [PATCH 01/12] #206 --- frontend/src/App.tsx | 2 + frontend/src/components/TopNav.tsx | 3 +- frontend/src/lib/prefetchers.ts | 2 +- frontend/src/pages/posts/PostSearchPage.tsx | 165 ++++++++++++++++++++ 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/posts/PostSearchPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 98fa8ce..f7c9545 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import PostDetailPage from '@/pages/posts/PostDetailPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostListPage from '@/pages/posts/PostListPage' import PostNewPage from '@/pages/posts/PostNewPage' +import PostSearchPage from '@/pages/posts/PostSearchPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' import SettingPage from '@/pages/users/SettingPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage' @@ -42,6 +43,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 144f517..06c30cf 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -69,8 +69,9 @@ export default (({ user }: Props) => { const menu: Menu = [ { name: '広場', to: '/posts', subMenu: [ { name: '一覧', to: '/posts' }, + { name: '検索', to: '/posts/search' }, { name: '投稿追加', to: '/posts/new' }, - { name: '耕作履歴', to: '/posts/changes' }, + { name: '履歴', to: '/posts/changes' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'タグ', to: '/tags', subMenu: [ { name: 'タグ一覧', to: '/tags', visible: false }, diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index d61291e..956f250 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -104,7 +104,7 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => { export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ { test: u => u.pathname === '/' || u.pathname === '/posts', 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))), run: prefetchPostShow }, { test: u => u.pathname === '/posts/changes', run: prefetchPostChanges }, diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx new file mode 100644 index 0000000..cb5067e --- /dev/null +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react' +import { Helmet } from 'react-helmet-async' +import { useLocation } from 'react-router-dom' + +import DateTimeField from '@/components/common/DateTimeField' +import Label from '@/components/common/Label' +import SectionTitle from '@/components/common/SectionTitle' +import MainArea from '@/components/layout/MainArea' +import { SITE_TITLE } from '@/config' +import { apiGet } from '@/lib/api' + +import type { FC, FormEvent } from 'react' + +import type { Post } from '@/types' + + +export default (() => { + const [createdFrom, setCreatedFrom] = useState (null) + const [createdTo, setCreatedTo] = useState (null) + const [matchType, setMatchType] = useState<'all' | 'any'> ('all') + const [originalCreatedFrom, setOriginalCreatedFrom] = useState (null) + const [originalCreatedTo, setOriginalCreatedTo] = useState (null) + const [tagsStr, setTagsStr] = useState ('') + const [title, setTitle] = useState ('') + const [updatedFrom, setUpdatedFrom] = useState (null) + const [updatedTo, setUpdatedTo] = useState (null) + const [url, setURL] = useState ('') + const [results, setResults] = useState ([]) + + const location = useLocation () + const query = new URLSearchParams (location.search) + const page = Number (query.get ('page') ?? 1) + const limit = Number (query.get ('limit') ?? 20) + + const search = async () => { + const tags = tagsStr.split (' ').filter (e => e !== '') + setResults (await apiGet ('/posts', { params: { + url, title, tags: tags.join (' '), match: matchType, + created_from: createdFrom, created_to: createdTo, + updated_from: updatedFrom, updated_to: updatedTo, + original_created_from: originalCreatedFrom, + original_created_to: originalCreatedTo, + page, limit } })) + } + + const handleSearch = (e: FormEvent) => { + e.preventDefault () + search () + } + + return ( + + + 広場検索 | {SITE_TITLE} + + +
+ 広場検索 +
+ {/* URL */} +
+ + setURL (e.target.value)} + className="w-full border p-2 rounded"/> +
+ + {/* タイトル */} +
+ + setTitle (e.target.value)} + className="w-full border p-2 rounded"/> +
+ + {/* タグ */} +
+ + setTagsStr (e.target.value)} + className="w-full border p-2 rounded"/> +
+ + + +
+
+ + {/* 投稿日時 */} +
+ + + + +
+ + {/* 更新日時 */} +
+ + + + +
+ + {/* オリジナルの投稿日時 */} +
+ + + + +
+ + {/* 検索 */} +
+ +
+
+
+ +
+ + + + + +
+
+
) +}) satisfies FC -- 2.34.1 From 3e5eb4687b3dd7df08614c3d4dc750a69912b5cb Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 25 Feb 2026 00:56:37 +0900 Subject: [PATCH 02/12] #206 --- frontend/src/lib/posts.ts | 30 +++-- frontend/src/lib/queryKeys.ts | 14 +- frontend/src/pages/posts/PostListPage.tsx | 2 +- frontend/src/pages/posts/PostSearchPage.tsx | 136 +++++++++++++++----- frontend/src/types.ts | 5 +- 5 files changed, 135 insertions(+), 52 deletions(-) diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 99c95f3..181945e 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -4,22 +4,28 @@ import type { Post, PostTagChange } from '@/types' export const fetchPosts = async ( - { tags, match, page, limit, cursor }: { - tags: string - match: 'any' | 'all' - page?: number - limit?: number - cursor?: string } + { url, title, tags, match, created_from, created_to, updated_from, + updated_to, original_created_from, original_created_to, page, limit }: { + url?: string + 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 }, ): Promise<{ posts: Post[] - count: number - nextCursor: string }> => + count: number }> => await apiGet ('/posts', { params: { - tags, - match, + url, title, tags, match, created_from, created_to, updated_from, updated_to, + original_created_from, original_created_to, ...(page && { page }), - ...(limit && { limit }), - ...(cursor && { cursor }) } }) + ...(limit && { limit }) } }) export const fetchPost = async (id: string): Promise => await apiGet (`/posts/${ id }`) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 909d54e..081afea 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -1,7 +1,17 @@ export const postsKeys = { root: ['posts'] as const, - index: (p: { tags: string; match: 'any' | 'all'; page: number; limit: number }) => - ['posts', 'index', p] as const, + index: (p: { url?: string + 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, related: (id: string) => ['related', id] as const, changes: (p: { id?: string; page: number; limit: number }) => diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 8dc20d6..95eca92 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -39,7 +39,7 @@ export default (() => { queryKey: postsKeys.index ({ tags: tagsKey, match, page, limit }), queryFn: () => fetchPosts ({ tags: tagsKey, match, page, limit }) }) const posts = data?.posts ?? [] - const cursor = data?.nextCursor ?? '' + const cursor = '' const totalPages = data ? Math.ceil (data.count / limit) : 0 useLayoutEffect (() => { diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index cb5067e..a03e834 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -2,12 +2,15 @@ import { useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation } 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 SectionTitle from '@/components/common/SectionTitle' +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 type { FC, FormEvent } from 'react' @@ -15,17 +18,18 @@ import type { Post } from '@/types' export default (() => { - const [createdFrom, setCreatedFrom] = useState (null) - const [createdTo, setCreatedTo] = useState (null) + const [createdFrom, setCreatedFrom] = useState () + const [createdTo, setCreatedTo] = useState () const [matchType, setMatchType] = useState<'all' | 'any'> ('all') - const [originalCreatedFrom, setOriginalCreatedFrom] = useState (null) - const [originalCreatedTo, setOriginalCreatedTo] = useState (null) + const [originalCreatedFrom, setOriginalCreatedFrom] = useState () + const [originalCreatedTo, setOriginalCreatedTo] = useState () const [tagsStr, setTagsStr] = useState ('') const [title, setTitle] = useState ('') - const [updatedFrom, setUpdatedFrom] = useState (null) - const [updatedTo, setUpdatedTo] = useState (null) + const [updatedFrom, setUpdatedFrom] = useState () + const [updatedTo, setUpdatedTo] = useState () const [url, setURL] = useState ('') const [results, setResults] = useState ([]) + const [totalPages, setTotalPages] = useState (0) const location = useLocation () const query = new URLSearchParams (location.search) @@ -34,13 +38,15 @@ export default (() => { const search = async () => { const tags = tagsStr.split (' ').filter (e => e !== '') - setResults (await apiGet ('/posts', { params: { + const data = await fetchPosts ({ url, title, tags: tags.join (' '), match: matchType, created_from: createdFrom, created_to: createdTo, updated_from: updatedFrom, updated_to: updatedTo, original_created_from: originalCreatedFrom, original_created_to: originalCreatedTo, - page, limit } })) + page, limit }) + setResults (data.posts) + setTotalPages (data ? Math.ceil (data.count / limit) : 0) } const handleSearch = (e: FormEvent) => { @@ -55,18 +61,9 @@ export default (() => {
- 広場検索 -
- {/* URL */} -
- - setURL (e.target.value)} - className="w-full border p-2 rounded"/> -
+ 広場検索 + {/* タイトル */}
@@ -77,6 +74,16 @@ export default (() => { className="w-full border p-2 rounded"/>
+ {/* URL */} +
+ + setURL (e.target.value)} + className="w-full border p-2 rounded"/> +
+ {/* タグ */}
@@ -111,11 +118,11 @@ export default (() => { + onChange={isoUTC => setCreatedFrom (isoUTC ?? undefined)}/> + onChange={isoUTC => setCreatedTo (isoUTC ?? undefined)}/>
{/* 更新日時 */} @@ -123,11 +130,11 @@ export default (() => { + onChange={isoUTC => setUpdatedFrom (isoUTC ?? undefined)}/> + onChange={isoUTC => setUpdatedTo (isoUTC ?? undefined)}/>
{/* オリジナルの投稿日時 */} @@ -135,11 +142,11 @@ export default (() => { + onChange={isoUTC => setOriginalCreatedFrom (isoUTC ?? undefined)}/> + onChange={isoUTC => setOriginalCreatedTo (isoUTC ?? undefined)}/> {/* 検索 */} @@ -153,13 +160,72 @@ export default (() => { -
- - - - - -
-
+ {results.length > 0 && ( +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {results.map (row => ( + + + + + + + + + ))} + +
投稿タイトルURLタグオリジナルの投稿日時投稿日時更新日時
+ + {row.title + + + + {row.title} + + + + {row.url} + + + {row.tags.map (t => ( + + + ))} + + {row.originalCreatedFrom} 〜 {row.originalCreatedBefore} + {row.createdAt}{row.updatedAt}
+
+ + +
)} ) }) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5cf9f5d..156f785 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -25,9 +25,10 @@ export type Post = { tags: Tag[] viewed: boolean related: Post[] - createdAt: string originalCreatedFrom: string | null - originalCreatedBefore: string | null } + originalCreatedBefore: string | null + createdAt: string + updatedAt: string } export type PostTagChange = { post: Post -- 2.34.1 From 663206c14c87e3f715ce4aaa068c91954f7aec46 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 26 Feb 2026 06:48:28 +0900 Subject: [PATCH 03/12] #206 --- backend/app/controllers/posts_controller.rb | 60 +++--- backend/spec/requests/posts_spec.rb | 201 +++++++++++++++++++- frontend/src/pages/posts/PostSearchPage.tsx | 26 +-- 3 files changed, 245 insertions(+), 42 deletions(-) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 33fef9a..34cb37d 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -2,38 +2,46 @@ class PostsController < ApplicationController Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) 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 limit = (params[:limit].presence || 20).to_i - cursor = params[:cursor].presence page = 1 if page < 1 limit = 1 if limit < 1 offset = (page - 1) * limit - sort_sql = - 'COALESCE(posts.original_created_before - INTERVAL 1 SECOND,' + - 'posts.original_created_from,' + - 'posts.created_at)' q = filtered_posts .preload(tags: { tag_name: :wiki_page }) .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 - if cursor && posts.length > limit - next_cursor = posts.last.read_attribute('sort_ts').iso8601(6) - posts = posts.first(limit) + + q = q.where('posts.url LIKE ?', "%#{ url }%") if url + q = q.where('posts.title LIKE ?', "%#{ title }%") if title + if original_created_from + q = q.where('posts.original_created_before > ?', original_created_from) 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| PostRepr.base(post).tap do |json| @@ -44,11 +52,7 @@ class PostsController < ApplicationController nil end end - }, count: if filtered_posts.group_values.present? - filtered_posts.count.size - else - filtered_posts.count - end, next_cursor: } + }, count: q.group_values.present? ? q.count.size : q.count } end def random @@ -63,7 +67,7 @@ class PostsController < ApplicationController end 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 viewed = current_user&.viewed?(post) || false @@ -84,7 +88,7 @@ class PostsController < ApplicationController title = params[:title].presence url = params[:url] 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_before = params[:original_created_before] @@ -125,7 +129,7 @@ class PostsController < ApplicationController return head :forbidden unless current_user.member? 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_before = params[:original_created_before] @@ -192,7 +196,7 @@ class PostsController < ApplicationController private def filtered_posts - tag_names = params[:tags].to_s.split(' ') + tag_names = params[:tags].to_s.split match_type = params[:match] if tag_names.present? filter_posts_by_tags(tag_names, match_type) diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 7ccdb52..c8621b1 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -1,7 +1,8 @@ +include ActiveSupport::Testing::TimeHelpers + require 'rails_helper' require 'set' - RSpec.describe 'Posts API', type: :request do # create / update で thumbnail.attach は走るが、 # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 @@ -114,6 +115,204 @@ RSpec.describe 'Posts API', type: :request do expect(json.fetch('count')).to eq(0) 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 describe 'GET /posts/:id' do diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index a03e834..41ca095 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -78,7 +78,7 @@ export default (() => {
setURL (e.target.value)} className="w-full border p-2 rounded"/> @@ -113,6 +113,18 @@ export default (() => {
+ {/* オリジナルの投稿日時 */} +
+ + setOriginalCreatedFrom (isoUTC ?? undefined)}/> + + setOriginalCreatedTo (isoUTC ?? undefined)}/> +
+ {/* 投稿日時 */}
@@ -137,18 +149,6 @@ export default (() => { onChange={isoUTC => setUpdatedTo (isoUTC ?? undefined)}/>
- {/* オリジナルの投稿日時 */} -
- - setOriginalCreatedFrom (isoUTC ?? undefined)}/> - - setOriginalCreatedTo (isoUTC ?? undefined)}/> -
- {/* 検索 */}
{/* 投稿日時 */} @@ -130,11 +145,11 @@ export default (() => { setCreatedFrom (isoUTC ?? undefined)}/> + onChange={setCreatedFrom}/> setCreatedTo (isoUTC ?? undefined)}/> + onChange={setCreatedTo}/> {/* 更新日時 */} @@ -142,11 +157,11 @@ export default (() => { setUpdatedFrom (isoUTC ?? undefined)}/> + onChange={setUpdatedFrom}/> setUpdatedTo (isoUTC ?? undefined)}/> + onChange={setUpdatedTo}/> {/* 検索 */} -- 2.34.1 From 4b447e952b05fc8b44f3d3a8d68084853c861a36 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 26 Feb 2026 23:33:09 +0900 Subject: [PATCH 05/12] #206 --- frontend/src/pages/posts/PostSearchPage.tsx | 108 ++++++++++++++------ 1 file changed, 74 insertions(+), 34 deletions(-) diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index c8b56fc..35b7f63 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' @@ -17,49 +17,89 @@ import { postsKeys } from '@/lib/queryKeys' import type { FC, FormEvent } from 'react' +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 location = useLocation () - const query = new URLSearchParams (location.search) + const query = useMemo (() => new URLSearchParams (location.search), + [location.search]) + const page = Number (query.get ('page') ?? 1) const limit = Number (query.get ('limit') ?? 20) - const [createdFrom, setCreatedFrom] = - useState (query.get ('created_from')) - const [createdTo, setCreatedTo] = - useState (query.get ('created_to')) - const [matchType, setMatchType] = - useState<'all' | 'any'> ((query.get ('match') as 'all' | 'any' | null) ?? 'all') - const [originalCreatedFrom, setOriginalCreatedFrom] = - useState (query.get ('original_created_from')) - const [originalCreatedTo, setOriginalCreatedTo] = - useState (query.get ('original_created_to')) - const [tagsStr, setTagsStr] = useState (query.get ('tags') ?? '') - const [title, setTitle] = useState (query.get ('title') ?? '') - const [updatedFrom, setUpdatedFrom] = - useState (query.get ('updated_from')) - const [updatedTo, setUpdatedTo] = - useState (query.get ('updated_to')) - const [url, setURL] = useState (query.get ('url') ?? '') + 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 [createdFrom, setCreatedFrom] = useState (qCreatedFrom) + const [createdTo, setCreatedTo] = useState (qCreatedTo) + const [matchType, setMatchType] = useState (qMatch ?? 'all') + const [originalCreatedFrom, setOriginalCreatedFrom] = useState (qOriginalCreatedFrom) + const [originalCreatedTo, setOriginalCreatedTo] = useState (qOriginalCreatedTo) + const [tagsStr, setTagsStr] = useState (qTags) + const [title, setTitle] = useState (qTitle ?? '') + const [updatedFrom, setUpdatedFrom] = useState (qUpdatedFrom) + const [updatedTo, setUpdatedTo] = useState (qUpdatedTo) + const [url, setURL] = useState (qURL ?? '') 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) }) + tags: qTags, match: qMatch, page, limit, + ...(qURL && { url: qURL }), + ...(qTitle && { title: qTitle }), + ...(qOriginalCreatedFrom && { original_created_from: qOriginalCreatedFrom }), + ...(qOriginalCreatedTo && { original_created_to: qOriginalCreatedTo }), + ...(qCreatedFrom && { created_from: qCreatedFrom }), + ...(qCreatedTo && { created_to: qCreatedTo }), + ...(qUpdatedFrom && { updated_from: qUpdatedFrom }), + ...(qUpdatedTo && { updated_to: qUpdatedTo }) } + 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.getElementsByTagName ('main')![0].scroll (0, 0) + }, [location.search]) + const search = async () => { - const qs = new URLSearchParams (location.search) + 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')) navigate (`${ location.pathname }?${ qs.toString () }`) } @@ -175,7 +215,7 @@ export default (() => { - {results.length > 0 && ( + {loading ? 'Loading...' : (results.length > 0 ? (
@@ -241,6 +281,6 @@ export default (() => { - )} + ) : '結果ないよ(笑)')} ) }) satisfies FC -- 2.34.1 From 7d554e395065d6d44f36f8f32f722b8793f7de6f Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 26 Feb 2026 23:53:08 +0900 Subject: [PATCH 06/12] #206 --- frontend/src/pages/posts/PostHistoryPage.tsx | 5 +++++ frontend/src/pages/posts/PostSearchPage.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index 3fd444e..dbf65dc 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation } from 'react-router-dom' @@ -31,6 +32,10 @@ export default (() => { const changes = data?.changes ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 + useEffect (() => { + document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) + }, [location.search]) + return ( diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 35b7f63..9d9c825 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -85,7 +85,7 @@ export default (() => { setUpdatedFrom (qUpdatedFrom) setUpdatedTo (qUpdatedTo) - document.getElementsByTagName ('main')![0].scroll (0, 0) + document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) }, [location.search]) const search = async () => { -- 2.34.1 From f8d2d753fec644f65d64b44f161ae345453734e0 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 1 Mar 2026 12:20:49 +0900 Subject: [PATCH 07/12] =?UTF-8?q?#206=20=E3=82=BF=E3=82=B0=E8=A3=9C?= =?UTF-8?q?=E5=AE=8C=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/PostFormTagsArea.tsx | 6 +- frontend/src/components/TagSearch.tsx | 2 + frontend/src/pages/posts/PostSearchPage.tsx | 82 +++++++++++++++++++- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index c7775af..92450c1 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -1,3 +1,5 @@ +// TODO: TagSearch と共通化する. + import { useRef, useState } from 'react' import TagSearchBox from '@/components/TagSearchBox' @@ -81,9 +83,7 @@ export default (({ tags, setTags }: Props) => { const pos = (ev.target as HTMLTextAreaElement).selectionStart await recompute (pos) }} - onFocus={() => { - setFocused (true) - }} + onFocus={() => setFocused (true)} onBlur={() => { setFocused (false) setSuggestionsVsbl (false) diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx index 6e7a8bd..13f72a4 100644 --- a/frontend/src/components/TagSearch.tsx +++ b/frontend/src/components/TagSearch.tsx @@ -1,3 +1,5 @@ +// TODO: タグ入力系すべてに同様の処理あるため共通化する. + import { useEffect, useState } from 'react' import { useNavigate, useLocation } from 'react-router-dom' diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 9d9c825..1ce25a9 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -5,16 +5,20 @@ 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 type { FC, FormEvent } from 'react' +import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react' + +import type { Tag } from '@/types' const setIf = (qs: URLSearchParams, k: string, v: string | null) => { @@ -46,11 +50,14 @@ export default (() => { const qUpdatedFrom = query.get ('updated_from') const qUpdatedTo = query.get ('updated_to') + const [activeIndex, setActiveIndex] = useState (-1) const [createdFrom, setCreatedFrom] = useState (qCreatedFrom) const [createdTo, setCreatedTo] = useState (qCreatedTo) const [matchType, setMatchType] = useState (qMatch ?? 'all') const [originalCreatedFrom, setOriginalCreatedFrom] = useState (qOriginalCreatedFrom) const [originalCreatedTo, setOriginalCreatedTo] = useState (qOriginalCreatedTo) + const [suggestions, setSuggestions] = useState ([]) + const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const [tagsStr, setTagsStr] = useState (qTags) const [title, setTitle] = useState (qTitle ?? '') const [updatedFrom, setUpdatedFrom] = useState (qUpdatedFrom) @@ -88,6 +95,58 @@ export default (() => { document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) }, [location.search]) + // TODO: TagSearch からのコピペのため,共通化を考へる. + const whenChanged = async (ev: ChangeEvent) => { + setTagsStr (ev.target.value) + + const q = ev.target.value.trim ().split (' ').at (-1) + if (!(q)) + { + setSuggestions ([]) + return + } + + const data = await apiGet ('/tags/autocomplete', { params: { q } }) + setSuggestions (data.filter (t => t.postCount > 0)) + if (suggestions.length > 0) + setSuggestionsVsbl (true) + } + + // TODO: TagSearch からのコピペのため,共通化を考へる. + const handleKeyDown = (ev: KeyboardEvent) => { + 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) @@ -104,6 +163,15 @@ export default (() => { 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 () @@ -140,13 +208,21 @@ export default (() => { {/* タグ */} -
+
setTagsStr (e.target.value)} + onChange={whenChanged} + onFocus={() => setSuggestionsVsbl (true)} + onBlur={() => setSuggestionsVsbl (false)} + onKeyDown={handleKeyDown} className="w-full border p-2 rounded"/> + 0 ? suggestions : [] as Tag[]} + activeIndex={activeIndex} + onSelect={handleTagSelect}/>
- + @@ -313,12 +336,22 @@ export default (() => { - - + + - - - + + + -- 2.34.1 From 1e54fb5be4f710d0129657c677aeb3cb96833311 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 2 Mar 2026 22:40:55 +0900 Subject: [PATCH 10/12] #206 --- frontend/src/components/TagDetailSidebar.tsx | 19 ++++++-------- frontend/src/lib/utils.ts | 19 ++++++++++++++ frontend/src/pages/posts/PostHistoryPage.tsx | 26 ++++++++++++++------ frontend/src/pages/posts/PostSearchPage.tsx | 24 ++++++++++++------ frontend/src/pages/wiki/WikiHistoryPage.tsx | 3 ++- frontend/src/pages/wiki/WikiSearchPage.tsx | 3 ++- 6 files changed, 65 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 64dbfe3..b4be734 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -19,6 +19,7 @@ import SidebarComponent from '@/components/layout/SidebarComponent' import { toast } from '@/components/ui/use-toast' import { CATEGORIES } from '@/consts' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' +import { dateString, originalCreatedAtString } from '@/lib/utils' import type { DragEndEvent } from '@dnd-kit/core' import type { FC, MutableRefObject, ReactNode } from 'react' @@ -343,7 +344,7 @@ export default (({ post }: Props) => { : 'bot操作'} */} -
  • 耕作日時: {(new Date (post.createdAt)).toLocaleString ()}
  • +
  • 耕作日時: {dateString (post.createdAt)}
  • <>リンク: {
  • - {/* TODO: 表示形式きしょすぎるので何とかする */} <>オリジナルの投稿日時: - {!(post.originalCreatedFrom) && !(post.originalCreatedBefore) - ? '不明' - : ( - <> - {post.originalCreatedFrom - && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} - {post.originalCreatedBefore - && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} - )} + {originalCreatedAtString (post.originalCreatedFrom, + post.originalCreatedBefore)}
  • - 履歴 + + 履歴 +
  • )} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 871bdd6..95caded 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -4,4 +4,23 @@ import { twMerge } from 'tailwind-merge' 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 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 (' ')) + || '不明' diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index dbf65dc..7fe2202 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query' +import { motion } from 'framer-motion' import { useEffect } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation } from 'react-router-dom' @@ -11,7 +12,7 @@ import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { fetchPostChanges } from '@/lib/posts' import { postsKeys } from '@/lib/queryKeys' -import { cn } from '@/lib/utils' +import { cn, dateString } from '@/lib/utils' import type { FC } from 'react' @@ -77,10 +78,17 @@ export default (() => {
    )} ) })} diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 58bf877..e100d5b 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -1,4 +1,5 @@ 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' @@ -15,6 +16,7 @@ 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' @@ -359,10 +361,17 @@ export default (() => { - - + + ))}
    投稿タイトルURL + + + + タグオリジナルの投稿日時投稿日時更新日時 + + + + + +
    - {change.post.title + + {change.post.title + @@ -88,12 +96,14 @@ export default (() => { {`を${ change.changeType === 'add' ? '記載' : '消除' }`} - {change.user ? ( + {change.user + ? ( {change.user.name} - ) : 'bot 操作'} + ) + : 'bot 操作'}
    - {change.timestamp} + {dateString (change.timestamp)}
    - {row.title + + {row.title + @@ -385,10 +394,11 @@ export default (() => { ))} - {row.originalCreatedFrom} 〜 {row.originalCreatedBefore} + {originalCreatedAtString (row.originalCreatedFrom, + row.originalCreatedBefore)} {row.createdAt}{row.updatedAt}{dateString (row.createdAt)}{dateString (row.updatedAt)}
    diff --git a/frontend/src/pages/wiki/WikiHistoryPage.tsx b/frontend/src/pages/wiki/WikiHistoryPage.tsx index 1ed278a..056e937 100644 --- a/frontend/src/pages/wiki/WikiHistoryPage.tsx +++ b/frontend/src/pages/wiki/WikiHistoryPage.tsx @@ -7,6 +7,7 @@ import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { apiGet } from '@/lib/api' +import { dateString } from '@/lib/utils' import type { WikiPageChange } from '@/types' @@ -65,7 +66,7 @@ export default () => { {change.user.name}
    - {change.timestamp} + {dateString (change.timestamp)} ))} diff --git a/frontend/src/pages/wiki/WikiSearchPage.tsx b/frontend/src/pages/wiki/WikiSearchPage.tsx index bc3e3e7..73f23a8 100644 --- a/frontend/src/pages/wiki/WikiSearchPage.tsx +++ b/frontend/src/pages/wiki/WikiSearchPage.tsx @@ -6,6 +6,7 @@ import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { apiGet } from '@/lib/api' +import { dateString } from '@/lib/utils' import type { FormEvent } from 'react' @@ -84,7 +85,7 @@ export default () => { - {page.updatedAt} + {dateString (page.updatedAt)} ))} -- 2.34.1 From 55dcd4e1f3c93a2b013287bed367a59c1974ac73 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 2 Mar 2026 23:29:22 +0900 Subject: [PATCH 11/12] #206 --- frontend/src/lib/prefetchers.ts | 46 ++++++++++-------- frontend/src/pages/posts/PostListPage.tsx | 9 +++- frontend/src/pages/posts/PostSearchPage.tsx | 54 ++++++++++----------- frontend/src/pages/wiki/WikiDetailPage.tsx | 9 +++- frontend/src/types.ts | 26 +++++----- 5 files changed, 80 insertions(+), 64 deletions(-) diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 56361c7..4461ae7 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -61,7 +61,20 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => { if (version) return - const p = { tags: effectiveTitle, match: 'all', page: 1, limit: 8 } as const + const p = { + 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 ({ queryKey: postsKeys.index (p), queryFn: () => fetchPosts (p) }) @@ -70,30 +83,23 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => { const prefetchPostsIndex: Prefetcher = async (qc, url) => { const tags = url.searchParams.get ('tags') ?? '' - 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 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 limit = Number (url.searchParams.get ('limit') || 20) - const order = url.searchParams.get ('order') as FetchPostsOrder | null + const order = (url.searchParams.get ('order') ?? 'original_created_at:desc') as FetchPostsOrder const keys = { - tags, match: m, page, limit, - ...(qURL && { url: qURL }), - ...(title && { title }), - ...(originalCreatedFrom && { originalCreatedFrom }), - ...(originalCreatedTo && { originalCreatedTo }), - ...(createdFrom && { createdFrom }), - ...(createdTo && { createdTo }), - ...(updatedFrom && { updatedFrom }), - ...(updatedTo && { updatedTo }), - ...(order && { order }) } + tags, match: m, page, limit, url: qURL, title, + originalCreatedFrom, originalCreatedTo, createdFrom, createdTo, + updatedFrom, updatedTo, order } await qc.prefetchQuery ({ queryKey: postsKeys.index (keys), diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 95eca92..70e3e68 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -35,9 +35,14 @@ export default (() => { const page = Number (query.get ('page') ?? 1) 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 ({ - queryKey: postsKeys.index ({ tags: tagsKey, match, page, limit }), - queryFn: () => fetchPosts ({ tags: tagsKey, match, page, limit }) }) + queryKey: postsKeys.index (keys), + queryFn: () => fetchPosts (keys) }) const posts = data?.posts ?? [] const cursor = '' const totalPages = data ? Math.ceil (data.count / limit) : 0 diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index e100d5b..727dced 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -44,43 +44,43 @@ export default (() => { 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 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 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 (qCreatedFrom) - const [createdTo, setCreatedTo] = useState (qCreatedTo) - const [matchType, setMatchType] = useState (qMatch ?? 'all') - const [originalCreatedFrom, setOriginalCreatedFrom] = useState (qOriginalCreatedFrom) - const [originalCreatedTo, setOriginalCreatedTo] = useState (qOriginalCreatedTo) + const [createdFrom, setCreatedFrom] = useState (null) + const [createdTo, setCreatedTo] = useState (null) + const [matchType, setMatchType] = useState<'all' | 'any'> ('all') + const [originalCreatedFrom, setOriginalCreatedFrom] = useState (null) + const [originalCreatedTo, setOriginalCreatedTo] = useState (null) const [suggestions, setSuggestions] = useState ([]) const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) - const [tagsStr, setTagsStr] = useState (qTags) - const [title, setTitle] = useState (qTitle ?? '') - const [updatedFrom, setUpdatedFrom] = useState (qUpdatedFrom) - const [updatedTo, setUpdatedTo] = useState (qUpdatedTo) - const [url, setURL] = useState (qURL ?? '') + const [tagsStr, setTagsStr] = useState ('') + const [title, setTitle] = useState ('') + const [updatedFrom, setUpdatedFrom] = useState (null) + const [updatedTo, setUpdatedTo] = useState (null) + const [url, setURL] = useState ('') const keys: FetchPostsParams = { tags: qTags, match: qMatch, page, limit, - ...(qURL && { url: qURL }), - ...(qTitle && { title: qTitle }), - ...(qOriginalCreatedFrom && { originalCreatedFrom: qOriginalCreatedFrom }), - ...(qOriginalCreatedTo && { originalCreatedTo: qOriginalCreatedTo }), - ...(qCreatedFrom && { createdFrom: qCreatedFrom }), - ...(qCreatedTo && { createdTo: qCreatedTo }), - ...(qUpdatedFrom && { updatedFrom: qUpdatedFrom }), - ...(qUpdatedTo && { updatedTo: qUpdatedTo }), - ...(order && { order }) } + 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) }) diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index eeb0b87..fd2c0b5 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.tsx @@ -44,10 +44,15 @@ export default () => { queryKey: tagsKeys.show (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 ({ enabled: Boolean (effectiveTitle) && !(version), - queryKey: postsKeys.index ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }), - queryFn: () => fetchPosts ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }) }) + queryKey: postsKeys.index (keys), + queryFn: () => fetchPosts (keys) }) const posts = data?.posts || [] useEffect (() => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7cecaa2..d7b0afd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -12,19 +12,19 @@ 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' - createdFrom?: string - createdTo?: string - updatedFrom?: string - updatedTo?: string - originalCreatedFrom?: string - originalCreatedTo?: string - page?: number - limit?: number - order?: FetchPostsOrder } + 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[] -- 2.34.1 From da45bc95ebadd853a1fc3129845636e8cb5d8eaf Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 3 Mar 2026 00:35:31 +0900 Subject: [PATCH 12/12] #206 --- backend/app/controllers/posts_controller.rb | 28 ++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index c89931f..b7659a5 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -30,8 +30,20 @@ class PostsController < ApplicationController offset = (page - 1) * limit + pt_max_sql = + PostTag + .select('post_id, MAX(updated_at) AS max_updated_at') + .group('post_id') + .to_sql + + updated_at_all_sql = + 'GREATEST(posts.updated_at,' + + 'COALESCE(pt_max.max_updated_at, posts.updated_at))' + q = 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 }) .with_attached_thumbnail @@ -45,8 +57,12 @@ class PostsController < ApplicationController 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] + 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 = if order[0] == 'original_created_at' @@ -57,12 +73,12 @@ class PostsController < ApplicationController else "posts.#{ order[0] } #{ order[1] }" end - posts = q.select('posts.*') - .order(Arel.sql("#{ sort_sql }")) - .limit(limit).offset(offset).to_a + posts = q.order(Arel.sql("#{ sort_sql }")).limit(limit).offset(offset).to_a + + q = q.except(:select, :order) render json: { posts: posts.map { |post| - PostRepr.base(post).tap do |json| + PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json| json['thumbnail'] = if post.thumbnail.attached? rails_storage_proxy_url(post.thumbnail, only_path: false) -- 2.34.1