| @@ -75,7 +75,7 @@ class PostsController < ApplicationController | |||||
| else | else | ||||
| "posts.#{ order[0] }" | "posts.#{ order[0] }" | ||||
| end | 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) | .limit(limit) | ||||
| .offset(offset) | .offset(offset) | ||||
| .to_a | .to_a | ||||
| @@ -2,18 +2,71 @@ class TagsController < ApplicationController | |||||
| def index | def index | ||||
| post_id = params[:post] | 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? | if post_id.present? | ||||
| Tag.joins(:posts, :tag_name) | Tag.joins(:posts, :tag_name) | ||||
| else | else | ||||
| Tag.joins(:tag_name) | Tag.joins(:tag_name) | ||||
| end | end | ||||
| .includes(:tag_name, tag_name: :wiki_page) | .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 | end | ||||
| def autocomplete | def autocomplete | ||||
| @@ -2,7 +2,8 @@ | |||||
| module TagRepr | 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 | module_function | ||||
| @@ -0,0 +1,31 @@ | |||||
| import { useLocation } from 'react-router-dom' | |||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| export default <T extends string,>({ by, label, currentOrder, defaultDirection }: { | |||||
| by: T | |||||
| label: string | |||||
| currentOrder: `${ T }:${ 'asc' | 'desc' }` | |||||
| defaultDirection: Record<T, 'asc' | 'desc'> }) => { | |||||
| 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 ( | |||||
| <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>) | |||||
| } | |||||
| @@ -48,7 +48,7 @@ const getPages = ( | |||||
| } | } | ||||
| export default (({ page, totalPages, siblingCount = 4 }) => { | |||||
| export default (({ page, totalPages, siblingCount = 2 }) => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const buildTo = (p: number) => { | const buildTo = (p: number) => { | ||||
| @@ -63,19 +63,31 @@ export default (({ page, totalPages, siblingCount = 4 }) => { | |||||
| <nav className="mt-4 flex justify-center" aria-label="Pagination"> | <nav className="mt-4 flex justify-center" aria-label="Pagination"> | ||||
| <div className="flex items-center gap-2"> | <div className="flex items-center gap-2"> | ||||
| {(page > 1) | {(page > 1) | ||||
| ? <PrefetchLink to={buildTo (page - 1)} aria-label="前のページ"><</PrefetchLink> | |||||
| : <span aria-hidden><</span>} | |||||
| ? ( | |||||
| <PrefetchLink | |||||
| className="p-2" | |||||
| to={buildTo (page - 1)} | |||||
| aria-label="前のページ"> | |||||
| < | |||||
| </PrefetchLink>) | |||||
| : <span className="p-2" aria-hidden><</span>} | |||||
| {pages.map ((p, idx) => ( | {pages.map ((p, idx) => ( | ||||
| (p === '…') | (p === '…') | ||||
| ? <span key={`dots-${ idx }`}>…</span> | |||||
| ? <span key={`dots-${ idx }`} className="p-2">…</span> | |||||
| : ((p === page) | : ((p === page) | ||||
| ? <span key={p} className="font-bold" aria-current="page">{p}</span> | |||||
| : <PrefetchLink key={p} to={buildTo (p)}>{p}</PrefetchLink>)))} | |||||
| ? <span key={p} className="font-bold p-2" aria-current="page">{p}</span> | |||||
| : <PrefetchLink key={p} className="p-2" to={buildTo (p)}>{p}</PrefetchLink>)))} | |||||
| {(page < totalPages) | {(page < totalPages) | ||||
| ? <PrefetchLink to={buildTo (page + 1)} aria-label="次のページ">></PrefetchLink> | |||||
| : <span aria-hidden>></span>} | |||||
| ? ( | |||||
| <PrefetchLink | |||||
| className="p-2" | |||||
| to={buildTo (page + 1)} | |||||
| aria-label="次のページ"> | |||||
| > | |||||
| </PrefetchLink>) | |||||
| : <span className="p-2" aria-hidden>></span>} | |||||
| </div> | </div> | ||||
| </nav>) | </nav>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -1,4 +1,4 @@ | |||||
| import type { FetchPostsParams } from '@/types' | |||||
| import type { FetchPostsParams, FetchTagsParams } from '@/types' | |||||
| export const postsKeys = { | export const postsKeys = { | ||||
| root: ['posts'] as const, | root: ['posts'] as const, | ||||
| @@ -9,9 +9,9 @@ export const postsKeys = { | |||||
| ['posts', 'changes', p] as const } | ['posts', 'changes', p] as const } | ||||
| export const tagsKeys = { | 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 = { | export const wikiKeys = { | ||||
| root: ['wiki'] as const, | root: ['wiki'] as const, | ||||
| @@ -1,22 +1,26 @@ | |||||
| import { apiGet } from '@/lib/api' | import { apiGet } from '@/lib/api' | ||||
| import type { Tag } from '@/types' | |||||
| import type { FetchTagsParams, Tag } from '@/types' | |||||
| export const fetchTags = async ( | 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[] | ): Promise<{ tags: Tag[] | ||||
| count: number }> => | count: number }> => | ||||
| await apiGet ('/tags', { params: { | await apiGet ('/tags', { params: { | ||||
| ...(post != null && { post }), | |||||
| ...(name && { name }), | ...(name && { name }), | ||||
| ...(category && { category }), | ...(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 }), | ...(createdFrom && { created_from: createdFrom }), | ||||
| ...(createdTo && { created_to: createdTo }), | ...(createdTo && { created_to: createdTo }), | ||||
| ...(updatedFrom && { updated_from: updatedFrom }), | ...(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<Tag | null> => { | export const fetchTagByName = async (name: string): Promise<Tag | null> => { | ||||
| @@ -5,6 +5,7 @@ import { Helmet } from 'react-helmet-async' | |||||
| import { useLocation, useNavigate } from 'react-router-dom' | import { useLocation, useNavigate } from 'react-router-dom' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| import SortHeader from '@/components/SortHeader' | |||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| import TagSearchBox from '@/components/TagSearchBox' | import TagSearchBox from '@/components/TagSearchBox' | ||||
| import DateTimeField from '@/components/common/DateTimeField' | import DateTimeField from '@/components/common/DateTimeField' | ||||
| @@ -102,28 +103,6 @@ export default (() => { | |||||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | ||||
| }, [location.search]) | }, [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 からのコピペのため,共通化を考へる. | // TODO: TagSearch からのコピペのため,共通化を考へる. | ||||
| const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => { | const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => { | ||||
| setTagsStr (ev.target.value) | setTagsStr (ev.target.value) | ||||
| @@ -188,7 +167,7 @@ export default (() => { | |||||
| setIf (qs, 'updated_from', updatedFrom) | setIf (qs, 'updated_from', updatedFrom) | ||||
| setIf (qs, 'updated_to', updatedTo) | setIf (qs, 'updated_to', updatedTo) | ||||
| qs.set ('match', matchType) | qs.set ('match', matchType) | ||||
| qs.set ('page', String ('1')) | |||||
| qs.set ('page', '1') | |||||
| qs.set ('order', order) | qs.set ('order', order) | ||||
| navigate (`${ location.pathname }?${ qs.toString () }`) | navigate (`${ location.pathname }?${ qs.toString () }`) | ||||
| } | } | ||||
| @@ -207,6 +186,12 @@ export default (() => { | |||||
| search () | search () | ||||
| } | } | ||||
| const defaultDirection = { title: 'asc', | |||||
| url: 'asc', | |||||
| original_created_at: 'desc', | |||||
| created_at: 'desc', | |||||
| updated_at: 'desc' } as const | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| <Helmet> | <Helmet> | ||||
| @@ -339,20 +324,40 @@ export default (() => { | |||||
| <tr> | <tr> | ||||
| <th className="p-2 text-left whitespace-nowrap">投稿</th> | <th className="p-2 text-left whitespace-nowrap">投稿</th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="title" label="タイトル"/> | |||||
| <SortHeader<FetchPostsOrderField> | |||||
| by="title" | |||||
| label="タイトル" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="url" label="URL"/> | |||||
| <SortHeader<FetchPostsOrderField> | |||||
| by="url" | |||||
| label="URL" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap">タグ</th> | <th className="p-2 text-left whitespace-nowrap">タグ</th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="original_created_at" label="オリジナルの投稿日時"/> | |||||
| <SortHeader<FetchPostsOrderField> | |||||
| by="original_created_at" | |||||
| label="オリジナルの投稿日時" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="created_at" label="投稿日時"/> | |||||
| <SortHeader<FetchPostsOrderField> | |||||
| by="created_at" | |||||
| label="投稿日時" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="updated_at" label="更新日時"/> | |||||
| <SortHeader<FetchPostsOrderField> | |||||
| by="updated_at" | |||||
| label="更新日時" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| </tr> | </tr> | ||||
| </thead> | </thead> | ||||
| @@ -1,23 +1,38 @@ | |||||
| import { useQuery } from '@tanstack/react-query' | import { useQuery } from '@tanstack/react-query' | ||||
| import { useEffect, useMemo, useState } from 'react' | import { useEffect, useMemo, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | 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 DateTimeField from '@/components/common/DateTimeField' | ||||
| import Label from '@/components/common/Label' | import Label from '@/components/common/Label' | ||||
| import PageTitle from '@/components/common/PageTitle' | import PageTitle from '@/components/common/PageTitle' | ||||
| import Pagination from '@/components/common/Pagination' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { CATEGORIES, CATEGORY_NAMES } from '@/consts' | import { CATEGORIES, CATEGORY_NAMES } from '@/consts' | ||||
| import { tagsKeys } from '@/lib/queryKeys' | 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 (() => { | export default (() => { | ||||
| const location = useLocation () | const location = useLocation () | ||||
| const navigate = useNavigate () | |||||
| const query = useMemo (() => new URLSearchParams (location.search), [location.search]) | const query = useMemo (() => new URLSearchParams (location.search), [location.search]) | ||||
| const page = Number (query.get ('page') ?? 1) | const page = Number (query.get ('page') ?? 1) | ||||
| @@ -26,7 +41,8 @@ export default (() => { | |||||
| const qName = query.get ('name') ?? '' | const qName = query.get ('name') ?? '' | ||||
| const qCategory = (query.get ('category') || null) as Category | null | const qCategory = (query.get ('category') || null) as Category | null | ||||
| const qPostCountGTE = Number (query.get ('post_count_gte') ?? 1) | 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 qCreatedFrom = query.get ('created_from') ?? '' | ||||
| const qCreatedTo = query.get ('created_to') ?? '' | const qCreatedTo = query.get ('created_to') ?? '' | ||||
| const qUpdatedFrom = query.get ('updated_from') ?? '' | const qUpdatedFrom = query.get ('updated_from') ?? '' | ||||
| @@ -36,17 +52,28 @@ export default (() => { | |||||
| const [name, setName] = useState ('') | const [name, setName] = useState ('') | ||||
| const [category, setCategory] = useState<Category | null> (null) | const [category, setCategory] = useState<Category | null> (null) | ||||
| const [postCountGTE, setPostCountGTE] = useState (1) | const [postCountGTE, setPostCountGTE] = useState (1) | ||||
| const [postCountLTE, setPostCountLTE] = useState (-1) | |||||
| const [postCountLTE, setPostCountLTE] = useState<number | null> (null) | |||||
| const [createdFrom, setCreatedFrom] = useState<string | null> (null) | const [createdFrom, setCreatedFrom] = useState<string | null> (null) | ||||
| const [createdTo, setCreatedTo] = useState<string | null> (null) | const [createdTo, setCreatedTo] = useState<string | null> (null) | ||||
| const [updatedFrom, setUpdatedFrom] = useState<string | null> (null) | const [updatedFrom, setUpdatedFrom] = useState<string | null> (null) | ||||
| const [updatedTo, setUpdatedTo] = useState<string | null> (null) | const [updatedTo, setUpdatedTo] = useState<string | null> (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 ({ | const { data, isLoading: loading } = useQuery ({ | ||||
| queryKey: tagsKeys.index (keys), | queryKey: tagsKeys.index (keys), | ||||
| queryFn: () => fetchTags (keys) }) | queryFn: () => fetchTags (keys) }) | ||||
| const results = data?.tags ?? [] | const results = data?.tags ?? [] | ||||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | |||||
| useEffect (() => { | useEffect (() => { | ||||
| setName (qName) | setName (qName) | ||||
| @@ -57,12 +84,36 @@ export default (() => { | |||||
| setCreatedTo (qCreatedTo) | setCreatedTo (qCreatedTo) | ||||
| setUpdatedFrom (qUpdatedFrom) | setUpdatedFrom (qUpdatedFrom) | ||||
| setUpdatedTo (qUpdatedTo) | 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 ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| <Helmet> | <Helmet> | ||||
| @@ -88,7 +139,7 @@ export default (() => { | |||||
| <Label>カテゴリ</Label> | <Label>カテゴリ</Label> | ||||
| <select | <select | ||||
| value={category ?? ''} | value={category ?? ''} | ||||
| onChange={e => setCategory(e.target.value || null)} | |||||
| onChange={e => setCategory((e.target.value || null) as Category | null)} | |||||
| className="w-full border p-2 rounded"> | className="w-full border p-2 rounded"> | ||||
| <option value=""> </option> | <option value=""> </option> | ||||
| {CATEGORIES.map (cat => ( | {CATEGORIES.map (cat => ( | ||||
| @@ -104,15 +155,15 @@ export default (() => { | |||||
| <input | <input | ||||
| type="number" | type="number" | ||||
| min="0" | min="0" | ||||
| value={postCountGTE < 0 ? '' : String (postCountGTE)} | |||||
| onChange={e => 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"/> | className="border rounded p-2"/> | ||||
| <span className="mx-1">〜</span> | <span className="mx-1">〜</span> | ||||
| <input | <input | ||||
| type="number" | type="number" | ||||
| min="0" | min="0" | ||||
| value={postCountLTE < 0 ? '' : String (postCountLTE)} | |||||
| onChange={e => 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"/> | className="border rounded p-2"/> | ||||
| </div> | </div> | ||||
| @@ -165,19 +216,39 @@ export default (() => { | |||||
| <thead className="border-b-2 border-black dark:border-white"> | <thead className="border-b-2 border-black dark:border-white"> | ||||
| <tr> | <tr> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="name" label="タグ"/> | |||||
| <SortHeader<FetchTagsOrderField> | |||||
| by="name" | |||||
| label="タグ" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="category" label="カテゴリ"/> | |||||
| <SortHeader<FetchTagsOrderField> | |||||
| by="category" | |||||
| label="カテゴリ" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="post_count" label="件数"/> | |||||
| <SortHeader<FetchTagsOrderField> | |||||
| by="post_count" | |||||
| label="件数" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="created_at" label="最初の記載日時"/> | |||||
| <SortHeader<FetchTagsOrderField> | |||||
| by="created_at" | |||||
| label="最初の記載日時" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader by="updated_at" label="更新日時"/> | |||||
| <SortHeader<FetchTagsOrderField> | |||||
| by="updated_at" | |||||
| label="更新日時" | |||||
| currentOrder={order} | |||||
| defaultDirection={defaultDirection}/> | |||||
| </th> | </th> | ||||
| </tr> | </tr> | ||||
| </thead> | </thead> | ||||
| @@ -189,13 +260,15 @@ export default (() => { | |||||
| <TagLink tag={row} withCount={false}/> | <TagLink tag={row} withCount={false}/> | ||||
| </td> | </td> | ||||
| <td className="p-2">{CATEGORY_NAMES[row.category]}</td> | <td className="p-2">{CATEGORY_NAMES[row.category]}</td> | ||||
| <td className="p-2">{dateString (row.postCount)}</td> | |||||
| <td className="p-2 text-right">{row.postCount}</td> | |||||
| <td className="p-2">{dateString (row.createdAt)}</td> | <td className="p-2">{dateString (row.createdAt)}</td> | ||||
| <td className="p-2">{dateString (row.updatedAt)}</td> | <td className="p-2">{dateString (row.updatedAt)}</td> | ||||
| </tr>))} | </tr>))} | ||||
| </tbody> | </tbody> | ||||
| </table> | </table> | ||||
| </div> | </div> | ||||
| <Pagination page={page} totalPages={totalPages}/> | |||||
| </div>) : '結果ないよ(笑)')} | </div>) : '結果ないよ(笑)')} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | }) satisfies FC | ||||
| @@ -32,9 +32,23 @@ export type FetchTagsOrderField = | |||||
| | 'name' | | 'name' | ||||
| | 'category' | | 'category' | ||||
| | 'post_count' | | 'post_count' | ||||
| | 'create_at' | |||||
| | 'created_at' | |||||
| | 'updated_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 Menu = MenuItem[] | ||||
| export type MenuItem = { | export type MenuItem = { | ||||
| @@ -80,6 +94,8 @@ export type Tag = { | |||||
| name: string | name: string | ||||
| category: Category | category: Category | ||||
| postCount: number | postCount: number | ||||
| createdAt: string | |||||
| updatedAt: string | |||||
| hasWiki: boolean | hasWiki: boolean | ||||
| children?: Tag[] | children?: Tag[] | ||||
| matchedAlias?: string | null } | matchedAlias?: string | null } | ||||