diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 0b99f73..980a2e9 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -75,7 +75,7 @@ class PostsController < ApplicationController else "posts.#{ order[0] }" end - posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, id #{ order[1] }")) + posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, posts.id #{ order[1] }")) .limit(limit) .offset(offset) .to_a diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index b97fcaa..9652f18 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -2,18 +2,71 @@ class TagsController < ApplicationController def index post_id = params[:post] - tags = + name = params[:name].presence + category = params[:category].presence + post_count_between = (params[:post_count_gte].presence || -1).to_i, + (params[:post_count_lte].presence || -1).to_i + post_count_between[0] = nil if post_count_between[0] < 0 + post_count_between[1] = nil if post_count_between[1] < 0 + 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?(['name', 'category', 'post_count', 'created_at', 'updated_at']) + order[0] = 'post_count' + end + unless order[1].in?(['asc', 'desc']) + order[1] = order[0].in?(['name', 'category']) ? 'asc' : 'desc' + end + + page = (params[:page].presence || 1).to_i + limit = (params[:limit].presence || 20).to_i + + page = 1 if page < 1 + limit = 1 if limit < 1 + + offset = (page - 1) * limit + + q = if post_id.present? Tag.joins(:posts, :tag_name) else Tag.joins(:tag_name) end .includes(:tag_name, tag_name: :wiki_page) - if post_id.present? - tags = tags.where(posts: { id: post_id }) - end + q = q.where(posts: { id: post_id }) if post_id.present? + + q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name + q = q.where(category: category) if category + q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0] + q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1] + q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0] + q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1] + q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0] + q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1] + + sort_sql = + case order[0] + when 'name' + 'tag_names.name' + when 'category' + 'CASE tags.category ' + + "WHEN 'deerjikist' THEN 0 " + + "WHEN 'meme' THEN 1 " + + "WHEN 'character' THEN 2 " + + "WHEN 'general' THEN 3 " + + "WHEN 'material' THEN 4 " + + "WHEN 'meta' THEN 5 " + + "WHEN 'nico' THEN 6 END" + else + "tags.#{ order[0] }" + end + tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }")) + .limit(limit) + .offset(offset) + .to_a - render json: TagRepr.base(tags) + render json: { tags: TagRepr.base(tags), count: q.size } end def autocomplete diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index cb2c470..db8b6eb 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -2,7 +2,8 @@ module TagRepr - BASE = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze + BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], + methods: [:name, :has_wiki] }.freeze module_function diff --git a/frontend/src/components/SortHeader.tsx b/frontend/src/components/SortHeader.tsx new file mode 100644 index 0000000..35239af --- /dev/null +++ b/frontend/src/components/SortHeader.tsx @@ -0,0 +1,31 @@ +import { useLocation } from 'react-router-dom' + +import PrefetchLink from '@/components/PrefetchLink' + + +export default ({ by, label, currentOrder, defaultDirection }: { + by: T + label: string + currentOrder: `${ T }:${ 'asc' | 'desc' }` + defaultDirection: Record }) => { + const [fld, dir] = currentOrder.split (':') + + const location = useLocation () + const qs = new URLSearchParams (location.search) + const nextDir = + (by === fld) + ? (dir === 'asc' ? 'desc' : 'asc') + : (defaultDirection[by] || 'desc') + qs.set ('order', `${ by }:${ nextDir }`) + qs.set ('page', '1') + + return ( + + + {label} + {by === fld && (dir === 'asc' ? ' ▲' : ' ▼')} + + ) +} diff --git a/frontend/src/components/common/Pagination.tsx b/frontend/src/components/common/Pagination.tsx index fc7dbde..53d045d 100644 --- a/frontend/src/components/common/Pagination.tsx +++ b/frontend/src/components/common/Pagination.tsx @@ -48,7 +48,7 @@ const getPages = ( } -export default (({ page, totalPages, siblingCount = 4 }) => { +export default (({ page, totalPages, siblingCount = 2 }) => { const location = useLocation () const buildTo = (p: number) => { @@ -63,19 +63,31 @@ export default (({ page, totalPages, siblingCount = 4 }) => { ) }) satisfies FC diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 614810f..b0d42f2 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -1,4 +1,4 @@ -import type { FetchPostsParams } from '@/types' +import type { FetchPostsParams, FetchTagsParams } from '@/types' export const postsKeys = { root: ['posts'] as const, @@ -9,9 +9,9 @@ export const postsKeys = { ['posts', 'changes', p] as const } export const tagsKeys = { - root: ['tags'] as const, - index: (p) => ['tags', 'index', p] as const, - show: (name: string) => ['tags', name] as const } + root: ['tags'] as const, + index: (p: FetchTagsParams) => ['tags', 'index', p] as const, + show: (name: string) => ['tags', name] as const } export const wikiKeys = { root: ['wiki'] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index de0e10b..2935934 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,22 +1,26 @@ import { apiGet } from '@/lib/api' -import type { Tag } from '@/types' +import type { FetchTagsParams, Tag } from '@/types' export const fetchTags = async ( - { name, category, postCountGTE, postCountLTE, createdFrom, createdTo, - updatedFrom, updatedTo }, + { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, + updatedFrom, updatedTo, page, limit, order }: FetchTagsParams, ): Promise<{ tags: Tag[] count: number }> => await apiGet ('/tags', { params: { + ...(post != null && { post }), ...(name && { name }), ...(category && { category }), - ...(postCountGTE && { post_count_gte: postCountGTE }), - ...(postCountLTE && { post_count_lte: postCountLTE }), + ...(postCountGTE != null && { post_count_gte: postCountGTE }), + ...(postCountLTE != null && { post_count_lte: postCountLTE }), ...(createdFrom && { created_from: createdFrom }), ...(createdTo && { created_to: createdTo }), ...(updatedFrom && { updated_from: updatedFrom }), - ...(updatedTo && { updated_to: updatedTo }) } }) + ...(updatedTo && { updated_to: updatedTo }), + ...(page && { page }), + ...(limit && { limit }), + ...(order && { order }) } }) export const fetchTagByName = async (name: string): Promise => { diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 057aaf2..a824953 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -5,6 +5,7 @@ import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' import PrefetchLink from '@/components/PrefetchLink' +import SortHeader from '@/components/SortHeader' import TagLink from '@/components/TagLink' import TagSearchBox from '@/components/TagSearchBox' import DateTimeField from '@/components/common/DateTimeField' @@ -102,28 +103,6 @@ export default (() => { document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) }, [location.search]) - const SortHeader = ({ by, label }: { by: FetchPostsOrderField; label: string }) => { - const [fld, dir] = order.split (':') - - const qs = new URLSearchParams (location.search) - const nextDir = - (by === fld) - ? (dir === 'asc' ? 'desc' : 'asc') - : (['title', 'url'].includes (by) ? 'asc' : 'desc') - qs.set ('order', `${ by }:${ nextDir }`) - qs.set ('page', '1') - - return ( - - - {label} - {by === fld && (dir === 'asc' ? ' ▲' : ' ▼')} - - ) - } - // TODO: TagSearch からのコピペのため,共通化を考へる. const whenChanged = async (ev: ChangeEvent) => { setTagsStr (ev.target.value) @@ -188,7 +167,7 @@ export default (() => { setIf (qs, 'updated_from', updatedFrom) setIf (qs, 'updated_to', updatedTo) qs.set ('match', matchType) - qs.set ('page', String ('1')) + qs.set ('page', '1') qs.set ('order', order) navigate (`${ location.pathname }?${ qs.toString () }`) } @@ -207,6 +186,12 @@ export default (() => { search () } + const defaultDirection = { title: 'asc', + url: 'asc', + original_created_at: 'desc', + created_at: 'desc', + updated_at: 'desc' } as const + return ( @@ -339,20 +324,40 @@ export default (() => { 投稿 - + + by="title" + label="タイトル" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="url" + label="URL" + currentOrder={order} + defaultDirection={defaultDirection}/> タグ - + + by="original_created_at" + label="オリジナルの投稿日時" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="created_at" + label="投稿日時" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="updated_at" + label="更新日時" + currentOrder={order} + defaultDirection={defaultDirection}/> diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index ccf15ce..9059a30 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -1,23 +1,38 @@ import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' +import SortHeader from '@/components/SortHeader' +import TagLink from '@/components/TagLink' 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 { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { tagsKeys } from '@/lib/queryKeys' +import { fetchTags } from '@/lib/tags' +import { dateString } from '@/lib/utils' -import type { FC } from 'react' +import type { FC, FormEvent } from 'react' -import type { Category, FetchTagsOrder } from '@/types' +import type { Category, FetchTagsOrder, FetchTagsOrderField } 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) @@ -26,7 +41,8 @@ export default (() => { const qName = query.get ('name') ?? '' const qCategory = (query.get ('category') || null) as Category | null const qPostCountGTE = Number (query.get ('post_count_gte') ?? 1) - const qPostCountLTE = Number (query.get ('post_count_lte') ?? (-1)) + const qPostCountLTE = + query.get ('post_count_lte') ? Number (query.get ('post_count_lte')) : null const qCreatedFrom = query.get ('created_from') ?? '' const qCreatedTo = query.get ('created_to') ?? '' const qUpdatedFrom = query.get ('updated_from') ?? '' @@ -36,17 +52,28 @@ export default (() => { const [name, setName] = useState ('') const [category, setCategory] = useState (null) const [postCountGTE, setPostCountGTE] = useState (1) - const [postCountLTE, setPostCountLTE] = useState (-1) + const [postCountLTE, setPostCountLTE] = useState (null) const [createdFrom, setCreatedFrom] = useState (null) const [createdTo, setCreatedTo] = useState (null) const [updatedFrom, setUpdatedFrom] = useState (null) const [updatedTo, setUpdatedTo] = useState (null) - const keys = { name: qName, category: qCategory } + const keys = { + page, limit, order, + post: null, + name: qName, + category: qCategory, + postCountGTE: qPostCountGTE, + postCountLTE: qPostCountLTE, + createdFrom: qCreatedFrom, + createdTo: qCreatedTo, + updatedFrom: qUpdatedFrom, + updatedTo: qUpdatedTo } const { data, isLoading: loading } = useQuery ({ queryKey: tagsKeys.index (keys), queryFn: () => fetchTags (keys) }) const results = data?.tags ?? [] + const totalPages = data ? Math.ceil (data.count / limit) : 0 useEffect (() => { setName (qName) @@ -57,12 +84,36 @@ export default (() => { setCreatedTo (qCreatedTo) setUpdatedFrom (qUpdatedFrom) setUpdatedTo (qUpdatedTo) - }, []) - const handleSearch = () => { - ; + document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) + }, [location.search]) + + const handleSearch = (e: FormEvent) => { + e.preventDefault () + + const qs = new URLSearchParams () + setIf (qs, 'name', name) + setIf (qs, 'category', category) + if (postCountGTE !== 1) + qs.set ('post_count_gte', String (postCountGTE)) + if (postCountLTE != null) + qs.set ('post_count_lte', String (postCountLTE)) + setIf (qs, 'created_from', createdFrom) + setIf (qs, 'created_to', createdTo) + setIf (qs, 'updated_from', updatedFrom) + setIf (qs, 'updated_to', updatedTo) + qs.set ('page', '1') + qs.set ('order', order) + + navigate (`${ location.pathname }?${ qs.toString () }`) } + const defaultDirection = { name: 'asc', + category: 'asc', + post_count: 'desc', + created_at: 'desc', + updated_at: 'desc' } as const + return ( @@ -88,7 +139,7 @@ export default (() => { setPostCountGTE (Number (e.target.value || (-1)))} + value={postCountGTE < 0 ? 0 : String (postCountGTE)} + onChange={e => setPostCountGTE (Number (e.target.value || 0))} className="border rounded p-2"/> setPostCountLTE (Number (e.target.value || (-1)))} + value={postCountLTE == null ? '' : String (postCountLTE)} + onChange={e => setPostCountLTE (e.target.value ? Number (e.target.value) : null)} className="border rounded p-2"/> @@ -165,19 +216,39 @@ export default (() => { - + + by="name" + label="タグ" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="category" + label="カテゴリ" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="post_count" + label="件数" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="created_at" + label="最初の記載日時" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="updated_at" + label="更新日時" + currentOrder={order} + defaultDirection={defaultDirection}/> @@ -189,13 +260,15 @@ export default (() => { {CATEGORY_NAMES[row.category]} - {dateString (row.postCount)} + {row.postCount} {dateString (row.createdAt)} {dateString (row.updatedAt)} ))} + + ) : '結果ないよ(笑)')} ) }) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index da7eb04..4dc8af5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -32,9 +32,23 @@ export type FetchTagsOrderField = | 'name' | 'category' | 'post_count' - | 'create_at' + | 'created_at' | 'updated_at' +export type FetchTagsParams = { + post: number | null + name: string + category: Category | null + postCountGTE: number + postCountLTE: number | null + createdFrom: string + createdTo: string + updatedFrom: string + updatedTo: string + page: number + limit: number + order: FetchTagsOrder } + export type Menu = MenuItem[] export type MenuItem = { @@ -80,6 +94,8 @@ export type Tag = { name: string category: Category postCount: number + createdAt: string + updatedAt: string hasWiki: boolean children?: Tag[] matchedAlias?: string | null }