feature/206 into main
| @@ -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) | ||||
| @@ -1,7 +1,8 @@ | |||||
| include ActiveSupport::Testing::TimeHelpers | |||||
| require 'rails_helper' | require 'rails_helper' | ||||
| require 'set' | require 'set' | ||||
| RSpec.describe 'Posts API', type: :request do | RSpec.describe 'Posts API', type: :request do | ||||
| # create / update で thumbnail.attach は走るが、 | # create / update で thumbnail.attach は走るが、 | ||||
| # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 | # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 | ||||
| @@ -114,6 +115,204 @@ RSpec.describe 'Posts API', type: :request do | |||||
| expect(json.fetch('count')).to eq(0) | expect(json.fetch('count')).to eq(0) | ||||
| end | end | ||||
| end | end | ||||
| context 'when url is provided' do | |||||
| let!(:url_hit_post) do | |||||
| Post.create!(uploaded_user: user, title: 'url hit', | |||||
| url: 'https://example.com/needle-url-xyz').tap do |p| | |||||
| PostTag.create!(post: p, tag:) | |||||
| end | |||||
| end | |||||
| let!(:url_miss_post) do | |||||
| Post.create!(uploaded_user: user, title: 'url miss', | |||||
| url: 'https://example.com/other-url').tap do |p| | |||||
| PostTag.create!(post: p, tag:) | |||||
| end | |||||
| end | |||||
| it 'filters posts by url substring' do | |||||
| get '/posts', params: { url: 'needle-url-xyz' } | |||||
| expect(response).to have_http_status(:ok) | |||||
| ids = json.fetch('posts').map { |p| p['id'] } | |||||
| expect(ids).to include(url_hit_post.id) | |||||
| expect(ids).not_to include(url_miss_post.id) | |||||
| expect(json.fetch('count')).to eq(1) | |||||
| end | |||||
| end | |||||
| context 'when title is provided' do | |||||
| let!(:title_hit_post) do | |||||
| Post.create!(uploaded_user: user, title: 'needle-title-xyz', | |||||
| url: 'https://example.com/title-hit').tap do |p| | |||||
| PostTag.create!(post: p, tag:) | |||||
| end | |||||
| end | |||||
| let!(:title_miss_post) do | |||||
| Post.create!(uploaded_user: user, title: 'other title', | |||||
| url: 'https://example.com/title-miss').tap do |p| | |||||
| PostTag.create!(post: p, tag:) | |||||
| end | |||||
| end | |||||
| it 'filters posts by title substring' do | |||||
| get '/posts', params: { title: 'needle-title-xyz' } | |||||
| expect(response).to have_http_status(:ok) | |||||
| ids = json.fetch('posts').map { |p| p['id'] } | |||||
| expect(ids).to include(title_hit_post.id) | |||||
| expect(ids).not_to include(title_miss_post.id) | |||||
| expect(json.fetch('count')).to eq(1) | |||||
| end | |||||
| end | |||||
| context 'when created_from/created_to are provided' do | |||||
| let(:t_created_hit) { Time.zone.local(2010, 1, 5, 12, 0, 0) } | |||||
| let(:t_created_miss) { Time.zone.local(2012, 1, 5, 12, 0, 0) } | |||||
| let!(:created_hit_post) do | |||||
| travel_to(t_created_hit) do | |||||
| Post.create!(uploaded_user: user, title: 'created hit', | |||||
| url: 'https://example.com/created-hit').tap do |p| | |||||
| PostTag.create!(post: p, tag:) | |||||
| end | |||||
| end | |||||
| end | |||||
| let!(:created_miss_post) do | |||||
| travel_to(t_created_miss) do | |||||
| Post.create!(uploaded_user: user, title: 'created miss', | |||||
| url: 'https://example.com/created-miss').tap do |p| | |||||
| PostTag.create!(post: p, tag:) | |||||
| end | |||||
| end | |||||
| end | |||||
| it 'filters posts by created_at range' do | |||||
| get '/posts', params: { | |||||
| created_from: Time.zone.local(2010, 1, 1, 0, 0, 0).iso8601, | |||||
| created_to: Time.zone.local(2010, 12, 31, 23, 59, 59).iso8601 | |||||
| } | |||||
| expect(response).to have_http_status(:ok) | |||||
| ids = json.fetch('posts').map { |p| p['id'] } | |||||
| expect(ids).to include(created_hit_post.id) | |||||
| expect(ids).not_to include(created_miss_post.id) | |||||
| expect(json.fetch('count')).to eq(1) | |||||
| end | |||||
| end | |||||
| context 'when updated_from/updated_to are provided' do | |||||
| let(:t0) { Time.zone.local(2011, 2, 1, 12, 0, 0) } | |||||
| let(:t1) { Time.zone.local(2011, 2, 10, 12, 0, 0) } | |||||
| let!(:updated_hit_post) do | |||||
| p = nil | |||||
| travel_to(t0) do | |||||
| p = Post.create!(uploaded_user: user, title: 'updated hit', | |||||
| url: 'https://example.com/updated-hit').tap do |pp| | |||||
| PostTag.create!(post: pp, tag:) | |||||
| end | |||||
| end | |||||
| travel_to(t1) do | |||||
| p.update!(title: 'updated hit v2') | |||||
| end | |||||
| p | |||||
| end | |||||
| let!(:updated_miss_post) do | |||||
| travel_to(Time.zone.local(2013, 1, 1, 12, 0, 0)) do | |||||
| Post.create!(uploaded_user: user, title: 'updated miss', | |||||
| url: 'https://example.com/updated-miss').tap do |p| | |||||
| PostTag.create!(post: p, tag:) | |||||
| end | |||||
| end | |||||
| end | |||||
| it 'filters posts by updated_at range' do | |||||
| get '/posts', params: { | |||||
| updated_from: Time.zone.local(2011, 2, 5, 0, 0, 0).iso8601, | |||||
| updated_to: Time.zone.local(2011, 2, 20, 23, 59, 59).iso8601 | |||||
| } | |||||
| expect(response).to have_http_status(:ok) | |||||
| ids = json.fetch('posts').map { |p| p['id'] } | |||||
| expect(ids).to include(updated_hit_post.id) | |||||
| expect(ids).not_to include(updated_miss_post.id) | |||||
| expect(json.fetch('count')).to eq(1) | |||||
| end | |||||
| end | |||||
| context 'when original_created_from/original_created_to are provided' do | |||||
| # 注意: controller の現状ロジックに合わせてる | |||||
| # original_created_from は `original_created_before > ?` | |||||
| # original_created_to は `original_created_from <= ?` | |||||
| let!(:oc_hit_post) do | |||||
| Post.create!(uploaded_user: user, title: 'oc hit', | |||||
| url: 'https://example.com/oc-hit', | |||||
| original_created_from: Time.zone.local(2015, 1, 1, 0, 0, 0), | |||||
| original_created_before: Time.zone.local(2015, 1, 10, 0, 0, 0)).tap do |p| | |||||
| PostTag.create!(post: p, tag:) | |||||
| end | |||||
| end | |||||
| # original_created_from の条件は「original_created_before > param」なので、 | |||||
| # before が param 以下になるようにする(ただし before >= from は守る) | |||||
| let!(:oc_miss_post_for_from) do | |||||
| Post.create!( | |||||
| uploaded_user: user, | |||||
| title: 'oc miss for from', | |||||
| url: 'https://example.com/oc-miss-from', | |||||
| original_created_from: Time.zone.local(2014, 12, 1, 0, 0, 0), | |||||
| original_created_before: Time.zone.local(2015, 1, 1, 0, 0, 0) | |||||
| ).tap { |p| PostTag.create!(post: p, tag:) } | |||||
| end | |||||
| # original_created_to の条件は「original_created_from <= param」なので、 | |||||
| # from が param より後になるようにする(before >= from は守る) | |||||
| let!(:oc_miss_post_for_to) do | |||||
| Post.create!( | |||||
| uploaded_user: user, | |||||
| title: 'oc miss for to', | |||||
| url: 'https://example.com/oc-miss-to', | |||||
| original_created_from: Time.zone.local(2015, 2, 1, 0, 0, 0), | |||||
| original_created_before: Time.zone.local(2015, 2, 10, 0, 0, 0) | |||||
| ).tap { |p| PostTag.create!(post: p, tag:) } | |||||
| end | |||||
| it 'filters posts by original_created_from (current controller behavior)' do | |||||
| get '/posts', params: { | |||||
| original_created_from: Time.zone.local(2015, 1, 5, 0, 0, 0).iso8601 | |||||
| } | |||||
| expect(response).to have_http_status(:ok) | |||||
| ids = json.fetch('posts').map { |p| p['id'] } | |||||
| expect(ids).to include(oc_hit_post.id) | |||||
| expect(ids).not_to include(oc_miss_post_for_from.id) | |||||
| expect(json.fetch('count')).to eq(2) | |||||
| end | |||||
| it 'filters posts by original_created_to (current controller behavior)' do | |||||
| get '/posts', params: { | |||||
| original_created_to: Time.zone.local(2015, 1, 15, 0, 0, 0).iso8601 | |||||
| } | |||||
| expect(response).to have_http_status(:ok) | |||||
| ids = json.fetch('posts').map { |p| p['id'] } | |||||
| expect(ids).to include(oc_hit_post.id) | |||||
| expect(ids).not_to include(oc_miss_post_for_to.id) | |||||
| expect(json.fetch('count')).to eq(2) | |||||
| end | |||||
| end | |||||
| end | end | ||||
| describe 'GET /posts/:id' do | describe 'GET /posts/:id' do | ||||
| @@ -16,6 +16,7 @@ import PostDetailPage from '@/pages/posts/PostDetailPage' | |||||
| import PostHistoryPage from '@/pages/posts/PostHistoryPage' | import PostHistoryPage from '@/pages/posts/PostHistoryPage' | ||||
| import PostListPage from '@/pages/posts/PostListPage' | import PostListPage from '@/pages/posts/PostListPage' | ||||
| import PostNewPage from '@/pages/posts/PostNewPage' | import PostNewPage from '@/pages/posts/PostNewPage' | ||||
| import PostSearchPage from '@/pages/posts/PostSearchPage' | |||||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | import ServiceUnavailable from '@/pages/ServiceUnavailable' | ||||
| import SettingPage from '@/pages/users/SettingPage' | import SettingPage from '@/pages/users/SettingPage' | ||||
| import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | ||||
| @@ -42,6 +43,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||||
| <Route path="/" element={<Navigate to="/posts" replace/>}/> | <Route path="/" element={<Navigate to="/posts" replace/>}/> | ||||
| <Route path="/posts" element={<PostListPage/>}/> | <Route path="/posts" element={<PostListPage/>}/> | ||||
| <Route path="/posts/new" element={<PostNewPage user={user}/>}/> | <Route path="/posts/new" element={<PostNewPage user={user}/>}/> | ||||
| <Route path="/posts/search" element={<PostSearchPage/>}/> | |||||
| <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | ||||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | <Route path="/posts/changes" element={<PostHistoryPage/>}/> | ||||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | ||||
| @@ -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) | ||||
| @@ -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>)} | ||||
| @@ -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' | ||||
| @@ -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 }, | ||||
| @@ -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 | |||||
| @@ -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 }`) | ||||
| @@ -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) | ||||
| } | } | ||||
| @@ -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 }) => | ||||
| @@ -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 (' ')) | |||||
| || '不明' | |||||
| @@ -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>) | ||||
| })} | })} | ||||
| @@ -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 (() => { | ||||
| @@ -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), | 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 (() => { | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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 | ||||