diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 33fef9a..b7659a5 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -2,41 +2,83 @@ 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 + + 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 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)' + 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 - .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) + + 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] + 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' + 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE,' + + 'posts.original_created_from,' + + 'posts.created_at) ' + + order[1] else - q.limit(limit).offset(offset) + "posts.#{ order[0] } #{ order[1] }" end - .to_a + posts = q.order(Arel.sql("#{ sort_sql }")).limit(limit).offset(offset).to_a - next_cursor = nil - if cursor && posts.length > limit - next_cursor = posts.last.read_attribute('sort_ts').iso8601(6) - posts = posts.first(limit) - end + 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) @@ -44,11 +86,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 +101,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 +122,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 +163,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 +230,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/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/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/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/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/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/consts.ts b/frontend/src/consts.ts index 214a19b..13968d2 100644 --- a/frontend/src/consts.ts +++ b/frontend/src/consts.ts @@ -3,13 +3,23 @@ import type { Category } from 'types' export const LIGHT_COLOUR_SHADE = 800 export const DARK_COLOUR_SHADE = 300 -export const CATEGORIES = ['deerjikist', - 'meme', - 'character', - 'general', - 'material', - 'meta', - 'nico'] as const +export const CATEGORIES = [ + 'deerjikist', + 'meme', + 'character', + 'general', + 'material', + 'meta', + 'nico', + ] as const + +export const FETCH_POSTS_ORDER_FIELDS = [ + 'title', + 'url', + 'original_created_at', + 'created_at', + 'updated_at', + ] as const export const TAG_COLOUR = { deerjikist: 'rose', @@ -18,10 +28,13 @@ export const TAG_COLOUR = { general: 'cyan', material: 'orange', meta: 'yellow', - nico: 'gray' } as const satisfies Record + nico: 'gray', + } as const satisfies Record export const USER_ROLES = ['admin', 'member', 'guest'] as const -export const ViewFlagBehavior = { OnShowedDetail: 1, - OnClickedLink: 2, - NotAuto: 3 } as const +export const ViewFlagBehavior = { + OnShowedDetail: 1, + OnClickedLink: 2, + NotAuto: 3, + } as const diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 99c95f3..04ec1ba 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -1,25 +1,28 @@ import { apiDelete, apiGet, apiPost } from '@/lib/api' -import type { Post, PostTagChange } from '@/types' +import type { FetchPostsParams, 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, createdFrom, createdTo, updatedFrom, updatedTo, + originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams ): Promise<{ - posts: Post[] - count: number - nextCursor: string }> => + posts: Post[] + count: number }> => await apiGet ('/posts', { params: { - tags, - match, + ...(url && { url }), + ...(title && { title }), + ...(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 }), ...(limit && { limit }), - ...(cursor && { cursor }) } }) + ...(order && { order }) } }) export const fetchPost = async (id: string): Promise => await apiGet (`/posts/${ id }`) diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index d61291e..4461ae7 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -8,6 +8,8 @@ import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' +import type { FetchPostsOrder } from '@/types' + type Prefetcher = (qc: QueryClient, url: URL) => Promise const mPost = match<{ id: string }> ('/posts/:id') @@ -59,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) }) @@ -68,13 +83,27 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => { const prefetchPostsIndex: Prefetcher = async (qc, url) => { 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 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 ({ - queryKey: postsKeys.index ({ tags, match: m, page, limit }), - queryFn: () => fetchPosts ({ tags, match: m, page, limit }) }) + queryKey: postsKeys.index (keys), + queryFn: () => fetchPosts (keys) }) } @@ -103,8 +132,9 @@ 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', '/posts/search'].includes (u.pathname), + run: prefetchPostsIndex }, + { 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 }, @@ -119,5 +149,6 @@ export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise< const r = routePrefetchers.find (x => x.test (u)) if (!(r)) return + await r.run (qc, u) } diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 909d54e..d8cbeef 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -1,7 +1,8 @@ +import type { FetchPostsParams } from '@/types' + 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: FetchPostsParams) => ['posts', 'index', p] as const, show: (id: string) => ['posts', id] as const, related: (id: string) => ['related', id] as const, changes: (p: { id?: string; page: number; limit: number }) => 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 3fd444e..7fe2202 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -1,4 +1,6 @@ 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' @@ -10,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' @@ -31,6 +33,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 ( @@ -72,10 +78,17 @@ export default (() => { - {change.post.title + + {change.post.title + )} @@ -83,12 +96,14 @@ export default (() => { {`を${ change.changeType === 'add' ? '記載' : '消除' }`} - {change.user ? ( + {change.user + ? ( {change.user.name} - ) : 'bot 操作'} + ) + : 'bot 操作'}
    - {change.timestamp} + {dateString (change.timestamp)} ) })} diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 8dc20d6..70e3e68 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -35,11 +35,16 @@ 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 = 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 new file mode 100644 index 0000000..727dced --- /dev/null +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -0,0 +1,410 @@ +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 (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 ('') + 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, + 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 ( + + + {label} + {by === fld && (dir === 'asc' ? ' ▲' : ' ▼')} + + ) + } + + // 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) + 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 ( + + + 広場検索 | {SITE_TITLE} + + +
    + 広場検索 + +
    + {/* タイトル */} +
    + + setTitle (e.target.value)} + className="w-full border p-2 rounded"/> +
    + + {/* URL */} +
    + + setURL (e.target.value)} + className="w-full border p-2 rounded"/> +
    + + {/* タグ */} +
    + + setSuggestionsVsbl (true)} + onBlur={() => setSuggestionsVsbl (false)} + onKeyDown={handleKeyDown} + className="w-full border p-2 rounded"/> + 0 ? suggestions : [] as Tag[]} + activeIndex={activeIndex} + onSelect={handleTagSelect}/> +
    + + + +
    +
    + + {/* オリジナルの投稿日時 */} +
    + + + + +
    + + {/* 投稿日時 */} +
    + + + + +
    + + {/* 更新日時 */} +
    + + + + +
    + + {/* 検索 */} +
    + +
    +
    +
    + + {loading ? 'Loading...' : (results.length > 0 ? ( +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + {results.map (row => ( + + + + + + + + + ))} + +
    投稿 + + + + タグ + + + + + +
    + + + {row.title + + + + + {row.title} + + + + {row.url} + + + {row.tags.map (t => ( + + + ))} + + {originalCreatedAtString (row.originalCreatedFrom, + row.originalCreatedBefore)} + {dateString (row.createdAt)}{dateString (row.updatedAt)}
    +
    + + +
    ) : '結果ないよ(笑)')} +
    ) +}) satisfies FC 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/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)} ))} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5cf9f5d..d7b0afd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,9 +1,31 @@ -import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' +import { CATEGORIES, + FETCH_POSTS_ORDER_FIELDS, + USER_ROLES, + ViewFlagBehavior } from '@/consts' import type { ReactNode } from 'react' 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 MenuItem = { @@ -25,9 +47,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