feature/206 into main
| @@ -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) | |||
| @@ -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 | |||
| @@ -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 }: { | |||
| <Route path="/" element={<Navigate to="/posts" replace/>}/> | |||
| <Route path="/posts" element={<PostListPage/>}/> | |||
| <Route path="/posts/new" element={<PostNewPage user={user}/>}/> | |||
| <Route path="/posts/search" element={<PostSearchPage/>}/> | |||
| <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | |||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | |||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | |||
| @@ -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) | |||
| @@ -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操作'} | |||
| </li> | |||
| */} | |||
| <li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li> | |||
| <li>耕作日時: {dateString (post.createdAt)}</li> | |||
| <li> | |||
| <>リンク: </> | |||
| <a | |||
| @@ -355,20 +356,14 @@ export default (({ post }: Props) => { | |||
| </a> | |||
| </li> | |||
| <li> | |||
| {/* TODO: 表示形式きしょすぎるので何とかする */} | |||
| <>オリジナルの投稿日時: </> | |||
| {!(post.originalCreatedFrom) && !(post.originalCreatedBefore) | |||
| ? '不明' | |||
| : ( | |||
| <> | |||
| {post.originalCreatedFrom | |||
| && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} | |||
| {post.originalCreatedBefore | |||
| && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} | |||
| </>)} | |||
| {originalCreatedAtString (post.originalCreatedFrom, | |||
| post.originalCreatedBefore)} | |||
| </li> | |||
| <li> | |||
| <PrefetchLink to={`/posts/changes?id=${ post.id }`}>履歴</PrefetchLink> | |||
| <PrefetchLink to={`/posts/changes?id=${ post.id }`}> | |||
| 履歴 | |||
| </PrefetchLink> | |||
| </li> | |||
| </ul> | |||
| </div>)} | |||
| @@ -1,3 +1,5 @@ | |||
| // TODO: タグ入力系すべてに同様の処理あるため共通化する. | |||
| import { useEffect, useState } from 'react' | |||
| import { useNavigate, useLocation } from 'react-router-dom' | |||
| @@ -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 }, | |||
| @@ -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<Category, string> | |||
| nico: 'gray', | |||
| } as const satisfies Record<Category, string> | |||
| 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 | |||
| @@ -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<Post> => await apiGet (`/posts/${ id }`) | |||
| @@ -8,6 +8,8 @@ import { fetchWikiPage, | |||
| fetchWikiPageByTitle, | |||
| fetchWikiPages } from '@/lib/wiki' | |||
| import type { FetchPostsOrder } from '@/types' | |||
| type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> | |||
| 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) | |||
| } | |||
| @@ -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 }) => | |||
| @@ -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 (' ')) | |||
| || '不明' | |||
| @@ -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 ( | |||
| <MainArea> | |||
| <Helmet> | |||
| @@ -72,10 +78,17 @@ export default (() => { | |||
| <td className="align-top p-2 bg-white dark:bg-[#242424] border-r" | |||
| rowSpan={rowsCnt}> | |||
| <PrefetchLink to={`/posts/${ change.post.id }`}> | |||
| <img src={change.post.thumbnail || change.post.thumbnailBase || undefined} | |||
| alt={change.post.title || change.post.url} | |||
| title={change.post.title || change.post.url || undefined} | |||
| className="w-40"/> | |||
| <motion.div | |||
| layoutId={`page-${ change.post.id }`} | |||
| transition={{ type: 'spring', | |||
| stiffness: 500, | |||
| damping: 40, | |||
| mass: .5 }}> | |||
| <img src={change.post.thumbnail || change.post.thumbnailBase || undefined} | |||
| alt={change.post.title || change.post.url} | |||
| title={change.post.title || change.post.url || undefined} | |||
| className="w-40"/> | |||
| </motion.div> | |||
| </PrefetchLink> | |||
| </td>)} | |||
| <td className="p-2"> | |||
| @@ -83,12 +96,14 @@ export default (() => { | |||
| {`を${ change.changeType === 'add' ? '記載' : '消除' }`} | |||
| </td> | |||
| <td className="p-2"> | |||
| {change.user ? ( | |||
| {change.user | |||
| ? ( | |||
| <PrefetchLink to={`/users/${ change.user.id }`}> | |||
| {change.user.name} | |||
| </PrefetchLink>) : 'bot 操作'} | |||
| </PrefetchLink>) | |||
| : 'bot 操作'} | |||
| <br/> | |||
| {change.timestamp} | |||
| {dateString (change.timestamp)} | |||
| </td> | |||
| </tr>) | |||
| })} | |||
| @@ -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 (() => { | |||
| @@ -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<string | null> (null) | |||
| const [createdTo, setCreatedTo] = useState<string | null> (null) | |||
| const [matchType, setMatchType] = useState<'all' | 'any'> ('all') | |||
| const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) | |||
| const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null) | |||
| const [suggestions, setSuggestions] = useState<Tag[]> ([]) | |||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | |||
| const [tagsStr, setTagsStr] = useState ('') | |||
| const [title, setTitle] = useState ('') | |||
| const [updatedFrom, setUpdatedFrom] = useState<string | null> (null) | |||
| const [updatedTo, setUpdatedTo] = useState<string | null> (null) | |||
| const [url, setURL] = useState ('') | |||
| const keys: FetchPostsParams = { | |||
| tags: qTags, match: qMatch, page, limit, | |||
| url: qURL, | |||
| title: qTitle, | |||
| originalCreatedFrom: qOriginalCreatedFrom, | |||
| originalCreatedTo: qOriginalCreatedTo, | |||
| createdFrom: qCreatedFrom, | |||
| createdTo: qCreatedTo, | |||
| updatedFrom: qUpdatedFrom, | |||
| updatedTo: qUpdatedTo, | |||
| order } | |||
| const { data, isLoading: loading } = useQuery ({ | |||
| queryKey: postsKeys.index (keys), | |||
| queryFn: () => fetchPosts (keys) }) | |||
| const results = data?.posts ?? [] | |||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | |||
| useEffect (() => { | |||
| setURL (qURL ?? '') | |||
| setTitle (qTitle ?? '') | |||
| setTagsStr (qTags ?? '') | |||
| setMatchType (qMatch ?? 'all') | |||
| setOriginalCreatedFrom (qOriginalCreatedFrom) | |||
| setOriginalCreatedTo (qOriginalCreatedTo) | |||
| setCreatedFrom (qCreatedFrom) | |||
| setCreatedTo (qCreatedTo) | |||
| setUpdatedFrom (qUpdatedFrom) | |||
| setUpdatedTo (qUpdatedTo) | |||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | |||
| }, [location.search]) | |||
| const SortHeader = ({ by, label }: { by: FetchPostsOrderField; label: string }) => { | |||
| const [fld, dir] = order.split (':') | |||
| const qs = new URLSearchParams (location.search) | |||
| const nextDir = | |||
| (by === fld) | |||
| ? (dir === 'asc' ? 'desc' : 'asc') | |||
| : (['title', 'url'].includes (by) ? 'asc' : 'desc') | |||
| qs.set ('order', `${ by }:${ nextDir }`) | |||
| qs.set ('page', '1') | |||
| return ( | |||
| <PrefetchLink | |||
| className="text-inherit visited:text-inherit hover:text-inherit" | |||
| to={`${ location.pathname }?${ qs.toString () }`}> | |||
| <span className="font-bold"> | |||
| {label} | |||
| {by === fld && (dir === 'asc' ? ' ▲' : ' ▼')} | |||
| </span> | |||
| </PrefetchLink>) | |||
| } | |||
| // TODO: TagSearch からのコピペのため,共通化を考へる. | |||
| const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => { | |||
| setTagsStr (ev.target.value) | |||
| const q = ev.target.value.trim ().split (' ').at (-1) | |||
| if (!(q)) | |||
| { | |||
| setSuggestions ([]) | |||
| return | |||
| } | |||
| const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } }) | |||
| setSuggestions (data.filter (t => t.postCount > 0)) | |||
| if (suggestions.length > 0) | |||
| setSuggestionsVsbl (true) | |||
| } | |||
| // TODO: TagSearch からのコピペのため,共通化を考へる. | |||
| const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => { | |||
| switch (ev.key) | |||
| { | |||
| case 'ArrowDown': | |||
| ev.preventDefault () | |||
| setActiveIndex (i => Math.min (i + 1, suggestions.length - 1)) | |||
| setSuggestionsVsbl (true) | |||
| break | |||
| case 'ArrowUp': | |||
| ev.preventDefault () | |||
| setActiveIndex (i => Math.max (i - 1, -1)) | |||
| setSuggestionsVsbl (true) | |||
| break | |||
| case 'Enter': | |||
| if (activeIndex < 0) | |||
| break | |||
| ev.preventDefault () | |||
| const selected = suggestions[activeIndex] | |||
| selected && handleTagSelect (selected) | |||
| break | |||
| case 'Escape': | |||
| ev.preventDefault () | |||
| setSuggestionsVsbl (false) | |||
| break | |||
| } | |||
| if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0)) | |||
| { | |||
| setSuggestionsVsbl (false) | |||
| } | |||
| } | |||
| const search = async () => { | |||
| const qs = new URLSearchParams () | |||
| setIf (qs, 'tags', tagsStr) | |||
| setIf (qs, 'url', url) | |||
| setIf (qs, 'title', title) | |||
| setIf (qs, 'original_created_from', originalCreatedFrom) | |||
| setIf (qs, 'original_created_to', originalCreatedTo) | |||
| setIf (qs, 'created_from', createdFrom) | |||
| setIf (qs, 'created_to', createdTo) | |||
| setIf (qs, 'updated_from', updatedFrom) | |||
| setIf (qs, 'updated_to', updatedTo) | |||
| qs.set ('match', matchType) | |||
| qs.set ('page', String ('1')) | |||
| qs.set ('order', order) | |||
| navigate (`${ location.pathname }?${ qs.toString () }`) | |||
| } | |||
| // TODO: TagSearch からのコピペのため,共通化を考へる. | |||
| const handleTagSelect = (tag: Tag) => { | |||
| const parts = tagsStr.split (' ') | |||
| parts[parts.length - 1] = tag.name | |||
| setTagsStr (parts.join (' ') + ' ') | |||
| setSuggestions ([]) | |||
| setActiveIndex (-1) | |||
| } | |||
| const handleSearch = (e: FormEvent) => { | |||
| e.preventDefault () | |||
| search () | |||
| } | |||
| return ( | |||
| <MainArea> | |||
| <Helmet> | |||
| <title>広場検索 | {SITE_TITLE}</title> | |||
| </Helmet> | |||
| <div className="max-w-xl"> | |||
| <PageTitle>広場検索</PageTitle> | |||
| <form onSubmit={handleSearch} className="space-y-2"> | |||
| {/* タイトル */} | |||
| <div> | |||
| <Label>タイトル</Label> | |||
| <input | |||
| type="text" | |||
| value={title} | |||
| onChange={e => setTitle (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* URL */} | |||
| <div> | |||
| <Label>URL</Label> | |||
| <input | |||
| type="text" | |||
| value={url} | |||
| onChange={e => setURL (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* タグ */} | |||
| <div className="relative"> | |||
| <Label>タグ</Label> | |||
| <input | |||
| type="text" | |||
| value={tagsStr} | |||
| onChange={whenChanged} | |||
| onFocus={() => setSuggestionsVsbl (true)} | |||
| onBlur={() => setSuggestionsVsbl (false)} | |||
| onKeyDown={handleKeyDown} | |||
| className="w-full border p-2 rounded"/> | |||
| <TagSearchBox | |||
| suggestions={ | |||
| suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]} | |||
| activeIndex={activeIndex} | |||
| onSelect={handleTagSelect}/> | |||
| <fieldset className="w-full my-2"> | |||
| <label>検索区分:</label> | |||
| <label className="mx-2"> | |||
| <input | |||
| type="radio" | |||
| name="match-type" | |||
| checked={matchType === 'all'} | |||
| onChange={() => setMatchType ('all')}/> | |||
| AND | |||
| </label> | |||
| <label className="mx-2"> | |||
| <input | |||
| type="radio" | |||
| name="match-type" | |||
| checked={matchType === 'any'} | |||
| onChange={() => setMatchType ('any')}/> | |||
| OR | |||
| </label> | |||
| </fieldset> | |||
| </div> | |||
| {/* オリジナルの投稿日時 */} | |||
| <div> | |||
| <Label>オリジナルの投稿日時</Label> | |||
| <DateTimeField | |||
| value={originalCreatedFrom ?? undefined} | |||
| onChange={setOriginalCreatedFrom}/> | |||
| <span className="mx-1">〜</span> | |||
| <DateTimeField | |||
| value={originalCreatedTo ?? undefined} | |||
| onChange={setOriginalCreatedTo}/> | |||
| </div> | |||
| {/* 投稿日時 */} | |||
| <div> | |||
| <Label>投稿日時</Label> | |||
| <DateTimeField | |||
| value={createdFrom ?? undefined} | |||
| onChange={setCreatedFrom}/> | |||
| <span className="mx-1">〜</span> | |||
| <DateTimeField | |||
| value={createdTo ?? undefined} | |||
| onChange={setCreatedTo}/> | |||
| </div> | |||
| {/* 更新日時 */} | |||
| <div> | |||
| <Label>更新日時</Label> | |||
| <DateTimeField | |||
| value={updatedFrom ?? undefined} | |||
| onChange={setUpdatedFrom}/> | |||
| <span className="mx-1">〜</span> | |||
| <DateTimeField | |||
| value={updatedTo ?? undefined} | |||
| onChange={setUpdatedTo}/> | |||
| </div> | |||
| {/* 検索 */} | |||
| <div className="py-3"> | |||
| <button | |||
| type="submit" | |||
| className="bg-blue-500 text-white px-4 py-2 rounded"> | |||
| 検索 | |||
| </button> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| {loading ? 'Loading...' : (results.length > 0 ? ( | |||
| <div className="mt-4"> | |||
| <div className="overflow-x-auto"> | |||
| <table className="w-full min-w-[1200px] table-fixed border-collapse"> | |||
| <colgroup> | |||
| <col className="w-14"/> | |||
| <col className="w-72"/> | |||
| <col className="w-80"/> | |||
| <col className="w-[24rem]"/> | |||
| <col className="w-60"/> | |||
| <col className="w-44"/> | |||
| <col className="w-44"/> | |||
| </colgroup> | |||
| <thead className="border-b-2 border-black dark:border-white"> | |||
| <tr> | |||
| <th className="p-2 text-left whitespace-nowrap">投稿</th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="title" label="タイトル"/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="url" label="URL"/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap">タグ</th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="original_created_at" label="オリジナルの投稿日時"/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="created_at" label="投稿日時"/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="updated_at" label="更新日時"/> | |||
| </th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {results.map (row => ( | |||
| <tr key={row.id} className={'even:bg-gray-100 dark:even:bg-gray-700'}> | |||
| <td className="p-2"> | |||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> | |||
| <motion.div | |||
| layoutId={`page-${ row.id }`} | |||
| transition={{ type: 'spring', | |||
| stiffness: 500, | |||
| damping: 40, | |||
| mass: .5 }}> | |||
| <img src={row.thumbnail || row.thumbnailBase || undefined} | |||
| alt={row.title || row.url} | |||
| title={row.title || row.url || undefined} | |||
| className="w-8"/> | |||
| </motion.div> | |||
| </PrefetchLink> | |||
| </td> | |||
| <td className="p-2 truncate"> | |||
| <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> | |||
| {row.title} | |||
| </PrefetchLink> | |||
| </td> | |||
| <td className="p-2 truncate"> | |||
| <a href={row.url} | |||
| title={row.url} | |||
| target="_blank" | |||
| rel="noopener noreferrer nofollow"> | |||
| {row.url} | |||
| </a> | |||
| </td> | |||
| <td className="p-2"> | |||
| {row.tags.map (t => ( | |||
| <span key={t.id} className="mr-2"> | |||
| <TagLink tag={t} withWiki={false} withCount={false}/> | |||
| </span>))} | |||
| </td> | |||
| <td className="p-2"> | |||
| {originalCreatedAtString (row.originalCreatedFrom, | |||
| row.originalCreatedBefore)} | |||
| </td> | |||
| <td className="p-2">{dateString (row.createdAt)}</td> | |||
| <td className="p-2">{dateString (row.updatedAt)}</td> | |||
| </tr>))} | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </div>) : '結果ないよ(笑)')} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| @@ -44,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 (() => { | |||
| @@ -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} | |||
| </PrefetchLink> | |||
| <br/> | |||
| {change.timestamp} | |||
| {dateString (change.timestamp)} | |||
| </td> | |||
| </tr>))} | |||
| </tbody> | |||
| @@ -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 () => { | |||
| </PrefetchLink> | |||
| </td> | |||
| <td className="p-2"> | |||
| {page.updatedAt} | |||
| {dateString (page.updatedAt)} | |||
| </td> | |||
| </tr>))} | |||
| </tbody> | |||
| @@ -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 | |||