#274 投稿検索ページ(#206)

Open
みてるぞ wants to merge 15 commits from feature/206 into main
  1. +65
    -27
      backend/app/controllers/posts_controller.rb
  2. +200
    -1
      backend/spec/requests/posts_spec.rb
  3. +2
    -0
      frontend/src/App.tsx
  4. +3
    -3
      frontend/src/components/PostFormTagsArea.tsx
  5. +7
    -12
      frontend/src/components/TagDetailSidebar.tsx
  6. +2
    -0
      frontend/src/components/TagSearch.tsx
  7. +2
    -1
      frontend/src/components/TopNav.tsx
  8. +24
    -11
      frontend/src/consts.ts
  9. +16
    -13
      frontend/src/lib/posts.ts
  10. +37
    -6
      frontend/src/lib/prefetchers.ts
  11. +3
    -2
      frontend/src/lib/queryKeys.ts
  12. +19
    -0
      frontend/src/lib/utils.ts
  13. +23
    -8
      frontend/src/pages/posts/PostHistoryPage.tsx
  14. +8
    -3
      frontend/src/pages/posts/PostListPage.tsx
  15. +410
    -0
      frontend/src/pages/posts/PostSearchPage.tsx
  16. +7
    -2
      frontend/src/pages/wiki/WikiDetailPage.tsx
  17. +2
    -1
      frontend/src/pages/wiki/WikiHistoryPage.tsx
  18. +2
    -1
      frontend/src/pages/wiki/WikiSearchPage.tsx
  19. +26
    -3
      frontend/src/types.ts

+ 65
- 27
backend/app/controllers/posts_controller.rb View File

@@ -2,41 +2,83 @@ class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)


def index def index
url = params[:url].presence
title = params[:title].presence
original_created_from = params[:original_created_from].presence
original_created_to = params[:original_created_to].presence
created_between = params[:created_from].presence, params[:created_to].presence
updated_between = params[:updated_from].presence, params[:updated_to].presence

order = params[:order].to_s.split(':', 2).map(&:strip)
unless order[0].in?(['title', 'url', 'original_created_at', 'created_at', 'updated_at'])
order[0] = 'original_created_at'
end
unless order[1].in?(['asc', 'desc'])
order[1] =
if order[0].in?(['title', 'url'])
'asc'
else
'desc'
end
end

page = (params[:page].presence || 1).to_i page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
cursor = params[:cursor].presence


page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1


offset = (page - 1) * limit offset = (page - 1) * limit


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 = q =
filtered_posts filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(tags: { tag_name: :wiki_page }) .preload(tags: { tag_name: :wiki_page })
.with_attached_thumbnail .with_attached_thumbnail
.select("posts.*, #{ sort_sql } AS sort_ts")
.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 else
q.limit(limit).offset(offset)
"posts.#{ order[0] } #{ order[1] }"
end 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| 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'] = json['thumbnail'] =
if post.thumbnail.attached? if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false) rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -44,11 +86,7 @@ class PostsController < ApplicationController
nil nil
end end
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 end


def random def random
@@ -63,7 +101,7 @@ class PostsController < ApplicationController
end end


def show def show
post = Post.includes(tags: { tag_name: :wiki_page }).find(params[:id])
post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post


viewed = current_user&.viewed?(post) || false viewed = current_user&.viewed?(post) || false
@@ -84,7 +122,7 @@ class PostsController < ApplicationController
title = params[:title].presence title = params[:title].presence
url = params[:url] url = params[:url]
thumbnail = params[:thumbnail] thumbnail = params[:thumbnail]
tag_names = params[:tags].to_s.split(' ')
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before] original_created_before = params[:original_created_before]


@@ -125,7 +163,7 @@ class PostsController < ApplicationController
return head :forbidden unless current_user.member? return head :forbidden unless current_user.member?


title = params[:title].presence title = params[:title].presence
tag_names = params[:tags].to_s.split(' ')
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before] original_created_before = params[:original_created_before]


@@ -192,7 +230,7 @@ class PostsController < ApplicationController
private private


def filtered_posts def filtered_posts
tag_names = params[:tags].to_s.split(' ')
tag_names = params[:tags].to_s.split
match_type = params[:match] match_type = params[:match]
if tag_names.present? if tag_names.present?
filter_posts_by_tags(tag_names, match_type) filter_posts_by_tags(tag_names, match_type)


+ 200
- 1
backend/spec/requests/posts_spec.rb View File

@@ -1,7 +1,8 @@
include ActiveSupport::Testing::TimeHelpers

require 'rails_helper' require 'rails_helper'
require 'set' require 'set'



RSpec.describe 'Posts API', type: :request do RSpec.describe 'Posts API', type: :request do
# create / update で thumbnail.attach は走るが、 # create / update で thumbnail.attach は走るが、
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
@@ -114,6 +115,204 @@ RSpec.describe 'Posts API', type: :request do
expect(json.fetch('count')).to eq(0) expect(json.fetch('count')).to eq(0)
end end
end end

context 'when url is provided' do
let!(:url_hit_post) do
Post.create!(uploaded_user: user, title: 'url hit',
url: 'https://example.com/needle-url-xyz').tap do |p|
PostTag.create!(post: p, tag:)
end
end

let!(:url_miss_post) do
Post.create!(uploaded_user: user, title: 'url miss',
url: 'https://example.com/other-url').tap do |p|
PostTag.create!(post: p, tag:)
end
end

it 'filters posts by url substring' do
get '/posts', params: { url: 'needle-url-xyz' }

expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }

expect(ids).to include(url_hit_post.id)
expect(ids).not_to include(url_miss_post.id)
expect(json.fetch('count')).to eq(1)
end
end

context 'when title is provided' do
let!(:title_hit_post) do
Post.create!(uploaded_user: user, title: 'needle-title-xyz',
url: 'https://example.com/title-hit').tap do |p|
PostTag.create!(post: p, tag:)
end
end

let!(:title_miss_post) do
Post.create!(uploaded_user: user, title: 'other title',
url: 'https://example.com/title-miss').tap do |p|
PostTag.create!(post: p, tag:)
end
end

it 'filters posts by title substring' do
get '/posts', params: { title: 'needle-title-xyz' }

expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }

expect(ids).to include(title_hit_post.id)
expect(ids).not_to include(title_miss_post.id)
expect(json.fetch('count')).to eq(1)
end
end

context 'when created_from/created_to are provided' do
let(:t_created_hit) { Time.zone.local(2010, 1, 5, 12, 0, 0) }
let(:t_created_miss) { Time.zone.local(2012, 1, 5, 12, 0, 0) }

let!(:created_hit_post) do
travel_to(t_created_hit) do
Post.create!(uploaded_user: user, title: 'created hit',
url: 'https://example.com/created-hit').tap do |p|
PostTag.create!(post: p, tag:)
end
end
end

let!(:created_miss_post) do
travel_to(t_created_miss) do
Post.create!(uploaded_user: user, title: 'created miss',
url: 'https://example.com/created-miss').tap do |p|
PostTag.create!(post: p, tag:)
end
end
end

it 'filters posts by created_at range' do
get '/posts', params: {
created_from: Time.zone.local(2010, 1, 1, 0, 0, 0).iso8601,
created_to: Time.zone.local(2010, 12, 31, 23, 59, 59).iso8601
}

expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }

expect(ids).to include(created_hit_post.id)
expect(ids).not_to include(created_miss_post.id)
expect(json.fetch('count')).to eq(1)
end
end

context 'when updated_from/updated_to are provided' do
let(:t0) { Time.zone.local(2011, 2, 1, 12, 0, 0) }
let(:t1) { Time.zone.local(2011, 2, 10, 12, 0, 0) }

let!(:updated_hit_post) do
p = nil
travel_to(t0) do
p = Post.create!(uploaded_user: user, title: 'updated hit',
url: 'https://example.com/updated-hit').tap do |pp|
PostTag.create!(post: pp, tag:)
end
end
travel_to(t1) do
p.update!(title: 'updated hit v2')
end
p
end

let!(:updated_miss_post) do
travel_to(Time.zone.local(2013, 1, 1, 12, 0, 0)) do
Post.create!(uploaded_user: user, title: 'updated miss',
url: 'https://example.com/updated-miss').tap do |p|
PostTag.create!(post: p, tag:)
end
end
end

it 'filters posts by updated_at range' do
get '/posts', params: {
updated_from: Time.zone.local(2011, 2, 5, 0, 0, 0).iso8601,
updated_to: Time.zone.local(2011, 2, 20, 23, 59, 59).iso8601
}

expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }

expect(ids).to include(updated_hit_post.id)
expect(ids).not_to include(updated_miss_post.id)
expect(json.fetch('count')).to eq(1)
end
end

context 'when original_created_from/original_created_to are provided' do
# 注意: controller の現状ロジックに合わせてる
# original_created_from は `original_created_before > ?`
# original_created_to は `original_created_from <= ?`

let!(:oc_hit_post) do
Post.create!(uploaded_user: user, title: 'oc hit',
url: 'https://example.com/oc-hit',
original_created_from: Time.zone.local(2015, 1, 1, 0, 0, 0),
original_created_before: Time.zone.local(2015, 1, 10, 0, 0, 0)).tap do |p|
PostTag.create!(post: p, tag:)
end
end

# original_created_from の条件は「original_created_before > param」なので、
# before が param 以下になるようにする(ただし before >= from は守る)
let!(:oc_miss_post_for_from) do
Post.create!(
uploaded_user: user,
title: 'oc miss for from',
url: 'https://example.com/oc-miss-from',
original_created_from: Time.zone.local(2014, 12, 1, 0, 0, 0),
original_created_before: Time.zone.local(2015, 1, 1, 0, 0, 0)
).tap { |p| PostTag.create!(post: p, tag:) }
end

# original_created_to の条件は「original_created_from <= param」なので、
# from が param より後になるようにする(before >= from は守る)
let!(:oc_miss_post_for_to) do
Post.create!(
uploaded_user: user,
title: 'oc miss for to',
url: 'https://example.com/oc-miss-to',
original_created_from: Time.zone.local(2015, 2, 1, 0, 0, 0),
original_created_before: Time.zone.local(2015, 2, 10, 0, 0, 0)
).tap { |p| PostTag.create!(post: p, tag:) }
end

it 'filters posts by original_created_from (current controller behavior)' do
get '/posts', params: {
original_created_from: Time.zone.local(2015, 1, 5, 0, 0, 0).iso8601
}

expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }

expect(ids).to include(oc_hit_post.id)
expect(ids).not_to include(oc_miss_post_for_from.id)
expect(json.fetch('count')).to eq(2)
end

it 'filters posts by original_created_to (current controller behavior)' do
get '/posts', params: {
original_created_to: Time.zone.local(2015, 1, 15, 0, 0, 0).iso8601
}

expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }

expect(ids).to include(oc_hit_post.id)
expect(ids).not_to include(oc_miss_post_for_to.id)
expect(json.fetch('count')).to eq(2)
end
end
end end


describe 'GET /posts/:id' do describe 'GET /posts/:id' do


+ 2
- 0
frontend/src/App.tsx View File

@@ -16,6 +16,7 @@ import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostListPage from '@/pages/posts/PostListPage' import PostListPage from '@/pages/posts/PostListPage'
import PostNewPage from '@/pages/posts/PostNewPage' import PostNewPage from '@/pages/posts/PostNewPage'
import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable' import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage' import SettingPage from '@/pages/users/SettingPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
@@ -42,6 +43,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/" element={<Navigate to="/posts" replace/>}/> <Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/> <Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/> <Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/> <Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>


+ 3
- 3
frontend/src/components/PostFormTagsArea.tsx View File

@@ -1,3 +1,5 @@
// TODO: TagSearch と共通化する.

import { useRef, useState } from 'react' import { useRef, useState } from 'react'


import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
@@ -81,9 +83,7 @@ export default (({ tags, setTags }: Props) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos) await recompute (pos)
}} }}
onFocus={() => {
setFocused (true)
}}
onFocus={() => setFocused (true)}
onBlur={() => { onBlur={() => {
setFocused (false) setFocused (false)
setSuggestionsVsbl (false) setSuggestionsVsbl (false)


+ 7
- 12
frontend/src/components/TagDetailSidebar.tsx View File

@@ -19,6 +19,7 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { dateString, originalCreatedAtString } from '@/lib/utils'


import type { DragEndEvent } from '@dnd-kit/core' import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react' import type { FC, MutableRefObject, ReactNode } from 'react'
@@ -343,7 +344,7 @@ export default (({ post }: Props) => {
: 'bot操作'} : 'bot操作'}
</li> </li>
*/} */}
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li>
<li>耕作日時: {dateString (post.createdAt)}</li>
<li> <li>
<>リンク: </> <>リンク: </>
<a <a
@@ -355,20 +356,14 @@ export default (({ post }: Props) => {
</a> </a>
</li> </li>
<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>
<li> <li>
<PrefetchLink to={`/posts/changes?id=${ post.id }`}>履歴</PrefetchLink>
<PrefetchLink to={`/posts/changes?id=${ post.id }`}>
履歴
</PrefetchLink>
</li> </li>
</ul> </ul>
</div>)} </div>)}


+ 2
- 0
frontend/src/components/TagSearch.tsx View File

@@ -1,3 +1,5 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する.

import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'




+ 2
- 1
frontend/src/components/TopNav.tsx View File

@@ -69,8 +69,9 @@ export default (({ user }: Props) => {
const menu: Menu = [ const menu: Menu = [
{ name: '広場', to: '/posts', subMenu: [ { name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' }, { name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '投稿追加', to: '/posts/new' }, { name: '投稿追加', to: '/posts/new' },
{ name: '耕作履歴', to: '/posts/changes' },
{ name: '履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [ { name: 'タグ', to: '/tags', subMenu: [
{ name: 'タグ一覧', to: '/tags', visible: false }, { name: 'タグ一覧', to: '/tags', visible: false },


+ 24
- 11
frontend/src/consts.ts View File

@@ -3,13 +3,23 @@ import type { Category } from 'types'
export const LIGHT_COLOUR_SHADE = 800 export const LIGHT_COLOUR_SHADE = 800
export const DARK_COLOUR_SHADE = 300 export const DARK_COLOUR_SHADE = 300


export const CATEGORIES = ['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 = { export const TAG_COLOUR = {
deerjikist: 'rose', deerjikist: 'rose',
@@ -18,10 +28,13 @@ export const TAG_COLOUR = {
general: 'cyan', general: 'cyan',
material: 'orange', material: 'orange',
meta: 'yellow', 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 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

+ 16
- 13
frontend/src/lib/posts.ts View File

@@ -1,25 +1,28 @@
import { apiDelete, apiGet, apiPost } from '@/lib/api' import { apiDelete, apiGet, apiPost } from '@/lib/api'


import type { Post, PostTagChange } from '@/types'
import type { FetchPostsParams, Post, PostTagChange } from '@/types'




export const fetchPosts = async ( 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<{ ): Promise<{
posts: Post[]
count: number
nextCursor: string }> =>
posts: Post[]
count: number }> =>
await apiGet ('/posts', { params: { 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 }), ...(page && { page }),
...(limit && { limit }), ...(limit && { limit }),
...(cursor && { cursor }) } })
...(order && { order }) } })




export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`) export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`)


+ 37
- 6
frontend/src/lib/prefetchers.ts View File

@@ -8,6 +8,8 @@ import { fetchWikiPage,
fetchWikiPageByTitle, fetchWikiPageByTitle,
fetchWikiPages } from '@/lib/wiki' fetchWikiPages } from '@/lib/wiki'


import type { FetchPostsOrder } from '@/types'

type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>


const mPost = match<{ id: string }> ('/posts/:id') const mPost = match<{ id: string }> ('/posts/:id')
@@ -59,7 +61,20 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
if (version) if (version)
return 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 ({ await qc.prefetchQuery ({
queryKey: postsKeys.index (p), queryKey: postsKeys.index (p),
queryFn: () => fetchPosts (p) }) queryFn: () => fetchPosts (p) })
@@ -68,13 +83,27 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {


const prefetchPostsIndex: Prefetcher = async (qc, url) => { const prefetchPostsIndex: Prefetcher = async (qc, url) => {
const tags = url.searchParams.get ('tags') ?? '' const tags = url.searchParams.get ('tags') ?? ''
const m = url.searchParams.get ('match') === 'any' ? 'any' : 'all'
const qURL = url.searchParams.get ('url') ?? ''
const title = url.searchParams.get ('title') ?? ''
const originalCreatedFrom = url.searchParams.get ('original_created_from') ?? ''
const originalCreatedTo = url.searchParams.get ('original_created_to') ?? ''
const createdFrom = url.searchParams.get ('created_from') ?? ''
const createdTo = url.searchParams.get ('created_to') ?? ''
const updatedFrom = url.searchParams.get ('updated_from') ?? ''
const updatedTo = url.searchParams.get ('updated_to') ?? ''
const m: 'all' | 'any' = url.searchParams.get ('match') === 'any' ? 'any' : 'all'
const page = Number (url.searchParams.get ('page') || 1) const page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20) const limit = Number (url.searchParams.get ('limit') || 20)
const order = (url.searchParams.get ('order') ?? 'original_created_at:desc') as FetchPostsOrder

const keys = {
tags, match: m, page, limit, url: qURL, title,
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
updatedFrom, updatedTo, order }


await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: postsKeys.index ({ 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 }[] = [ 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))), && Boolean (mPost (u.pathname))),
run: prefetchPostShow }, run: prefetchPostShow },
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges }, { test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
@@ -119,5 +149,6 @@ export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<
const r = routePrefetchers.find (x => x.test (u)) const r = routePrefetchers.find (x => x.test (u))
if (!(r)) if (!(r))
return return

await r.run (qc, u) await r.run (qc, u)
} }

+ 3
- 2
frontend/src/lib/queryKeys.ts View File

@@ -1,7 +1,8 @@
import type { FetchPostsParams } from '@/types'

export const postsKeys = { export const postsKeys = {
root: ['posts'] as const, root: ['posts'] as const,
index: (p: { tags: string; match: 'any' | 'all'; page: number; limit: number }) =>
['posts', 'index', p] as const,
index: (p: FetchPostsParams) => ['posts', 'index', p] as const,
show: (id: string) => ['posts', id] as const, show: (id: string) => ['posts', id] as const,
related: (id: string) => ['related', id] as const, related: (id: string) => ['related', id] as const,
changes: (p: { id?: string; page: number; limit: number }) => changes: (p: { id?: string; page: number; limit: number }) =>


+ 19
- 0
frontend/src/lib/utils.ts View File

@@ -4,4 +4,23 @@ import { twMerge } from 'tailwind-merge'
import type { ClassValue } from 'clsx' import type { ClassValue } from 'clsx'




export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Date (d) : d


export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs)) export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))


export const dateString = (d: string | Date): string =>
toDate (d).toLocaleString ('ja-JP-u-ca-japanese')


// TODO: 表示形式きしょすぎるので何とかする
export const originalCreatedAtString = (
f: string | Date | null,
b: string | Date | null,
): string =>
([f ? `${ dateString (f) } 以降` : '',
b ? `${ dateString (b) } より前` : '']
.filter (Boolean)
.join (' '))
|| '不明'

+ 23
- 8
frontend/src/pages/posts/PostHistoryPage.tsx View File

@@ -1,4 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'


@@ -10,7 +12,7 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchPostChanges } from '@/lib/posts' import { fetchPostChanges } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' import { postsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import { cn, dateString } from '@/lib/utils'


import type { FC } from 'react' import type { FC } from 'react'


@@ -31,6 +33,10 @@ export default (() => {
const changes = data?.changes ?? [] const changes = data?.changes ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0


useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])

return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
@@ -72,10 +78,17 @@ export default (() => {
<td className="align-top p-2 bg-white dark:bg-[#242424] border-r" <td className="align-top p-2 bg-white dark:bg-[#242424] border-r"
rowSpan={rowsCnt}> rowSpan={rowsCnt}>
<PrefetchLink to={`/posts/${ change.post.id }`}> <PrefetchLink to={`/posts/${ change.post.id }`}>
<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> </PrefetchLink>
</td>)} </td>)}
<td className="p-2"> <td className="p-2">
@@ -83,12 +96,14 @@ export default (() => {
{`を${ change.changeType === 'add' ? '記載' : '消除' }`} {`を${ change.changeType === 'add' ? '記載' : '消除' }`}
</td> </td>
<td className="p-2"> <td className="p-2">
{change.user ? (
{change.user
? (
<PrefetchLink to={`/users/${ change.user.id }`}> <PrefetchLink to={`/users/${ change.user.id }`}>
{change.user.name} {change.user.name}
</PrefetchLink>) : 'bot 操作'}
</PrefetchLink>)
: 'bot 操作'}
<br/> <br/>
{change.timestamp}
{dateString (change.timestamp)}
</td> </td>
</tr>) </tr>)
})} })}


+ 8
- 3
frontend/src/pages/posts/PostListPage.tsx View File

@@ -35,11 +35,16 @@ export default (() => {
const page = Number (query.get ('page') ?? 1) const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20) const limit = Number (query.get ('limit') ?? 20)


const keys = {
tags: tagsKey, match, page, limit,
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const
const { data, isLoading: loading } = useQuery ({ const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index ({ tags: tagsKey, match, page, limit }),
queryFn: () => fetchPosts ({ tags: tagsKey, match, page, limit }) })
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
const posts = data?.posts ?? [] const posts = data?.posts ?? []
const cursor = data?.nextCursor ?? ''
const cursor = ''
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0


useLayoutEffect (() => { useLayoutEffect (() => {


+ 410
- 0
frontend/src/pages/posts/PostSearchPage.tsx View File

@@ -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

+ 7
- 2
frontend/src/pages/wiki/WikiDetailPage.tsx View File

@@ -44,10 +44,15 @@ export default () => {
queryKey: tagsKeys.show (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) }) queryFn: () => fetchTagByName (effectiveTitle) })


const keys = {
tags: effectiveTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const
const { data } = useQuery ({ const { data } = useQuery ({
enabled: Boolean (effectiveTitle) && !(version), enabled: Boolean (effectiveTitle) && !(version),
queryKey: postsKeys.index ({ 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 || [] const posts = data?.posts || []


useEffect (() => { useEffect (() => {


+ 2
- 1
frontend/src/pages/wiki/WikiHistoryPage.tsx View File

@@ -7,6 +7,7 @@ import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import { dateString } from '@/lib/utils'


import type { WikiPageChange } from '@/types' import type { WikiPageChange } from '@/types'


@@ -65,7 +66,7 @@ export default () => {
{change.user.name} {change.user.name}
</PrefetchLink> </PrefetchLink>
<br/> <br/>
{change.timestamp}
{dateString (change.timestamp)}
</td> </td>
</tr>))} </tr>))}
</tbody> </tbody>


+ 2
- 1
frontend/src/pages/wiki/WikiSearchPage.tsx View File

@@ -6,6 +6,7 @@ import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import { dateString } from '@/lib/utils'


import type { FormEvent } from 'react' import type { FormEvent } from 'react'


@@ -84,7 +85,7 @@ export default () => {
</PrefetchLink> </PrefetchLink>
</td> </td>
<td className="p-2"> <td className="p-2">
{page.updatedAt}
{dateString (page.updatedAt)}
</td> </td>
</tr>))} </tr>))}
</tbody> </tbody>


+ 26
- 3
frontend/src/types.ts View File

@@ -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' import type { ReactNode } from 'react'


export type Category = typeof CATEGORIES[number] export type Category = typeof CATEGORIES[number]


export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }`

export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number]

export type FetchPostsParams = {
url: string
title: string
tags: string
match: 'all' | 'any'
originalCreatedFrom: string
originalCreatedTo: string
createdFrom: string
createdTo: string
updatedFrom: string
updatedTo: string
page: number
limit: number
order: FetchPostsOrder }

export type Menu = MenuItem[] export type Menu = MenuItem[]


export type MenuItem = { export type MenuItem = {
@@ -25,9 +47,10 @@ export type Post = {
tags: Tag[] tags: Tag[]
viewed: boolean viewed: boolean
related: Post[] related: Post[]
createdAt: string
originalCreatedFrom: string | null originalCreatedFrom: string | null
originalCreatedBefore: string | null }
originalCreatedBefore: string | null
createdAt: string
updatedAt: string }


export type PostTagChange = { export type PostTagChange = {
post: Post post: Post


Loading…
Cancel
Save