タグ一覧ページの作成(#61) (#298)

#61

#61

Merge remote-tracking branch 'origin/main' into feature/061

#61

#61

#61

#61

#61

#61

#61

#61

#61

#61

日づけ不詳の表示修正

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #298
このコミットはPull リクエスト #298 でマージされました.
このコミットが含まれているのは:
2026-03-21 19:58:02 +09:00
コミット ee93ff8ea0
26個のファイルの変更1135行の追加283行の削除
+4 -3
ファイルの表示
@@ -14,7 +14,7 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { fetchPost, toggleViewedFlg } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
@@ -101,7 +101,7 @@ export default (({ user }: Props) => {
</Helmet>
<div className="hidden md:block">
<TagDetailSidebar post={post ?? null}/>
{post && <TagDetailSidebar post={post}/>}
</div>
<MainArea className="relative">
@@ -145,6 +145,7 @@ export default (({ user }: Props) => {
qc.setQueryData (postsKeys.show (postId),
(prev: any) => newPost ?? prev)
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '更新しました.' })
}}/>
</Tab>)}
@@ -154,7 +155,7 @@ export default (({ user }: Props) => {
</MainArea>
<div className="md:hidden">
<TagDetailSidebar post={post ?? null}/>
{post && <TagDetailSidebar post={post} sp/>}
</div>
</div>)
}) satisfies FC<Props>
+16 -3
ファイルの表示
@@ -11,7 +11,8 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchPostChanges } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { cn, dateString } from '@/lib/utils'
import type { FC } from 'react'
@@ -21,15 +22,26 @@ export default (() => {
const location = useLocation ()
const query = new URLSearchParams (location.search)
const id = query.get ('id')
const tagId = query.get ('tag')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
// 投稿列の結合で使用
let rowsCnt: number
const { data: tag } =
tagId
? useQuery ({ queryKey: tagsKeys.show (tagId),
queryFn: () => fetchTag (tagId) })
: { data: null }
const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.changes ({ ...(id && { id }), page, limit }),
queryFn: () => fetchPostChanges ({ ...(id && { id }), page, limit }) })
queryKey: postsKeys.changes ({ ...(id && { id }),
...(tagId && { tag: tagId }),
page, limit }),
queryFn: () => fetchPostChanges ({ ...(id && { id }),
...(tagId && { tag: tagId }),
page, limit }) })
const changes = data?.changes ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
@@ -48,6 +60,7 @@ export default (() => {
<PageTitle>
{id && <>: 稿 {<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>}</>}
{tag && <><TagLink tag={tag} withWiki={false} withCount={false}/></>}
</PageTitle>
{loading ? 'Loading...' : (
+34 -29
ファイルの表示
@@ -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 (
<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)
@@ -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 (
<MainArea>
<Helmet>
@@ -339,26 +324,46 @@ export default (() => {
<tr>
<th className="p-2 text-left whitespace-nowrap">稿</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="title" label="タイトル"/>
<SortHeader<FetchPostsOrderField>
by="title"
label="タイトル"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<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 className="p-2 text-left whitespace-nowrap"></th>
<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 className="p-2 text-left whitespace-nowrap">
<SortHeader by="created_at" label="投稿日時"/>
<SortHeader<FetchPostsOrderField>
by="created_at"
label="投稿日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="updated_at" label="更新日時"/>
<SortHeader<FetchPostsOrderField>
by="updated_at"
label="更新日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
</tr>
</thead>
<tbody>
{results.map (row => (
<tr key={row.id} className={'even:bg-gray-100 dark:even:bg-gray-700'}>
<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
+282
ファイルの表示
@@ -0,0 +1,282 @@
import { useQuery } from '@tanstack/react-query'
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 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, FormEvent } from 'react'
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)
const limit = Number (query.get ('limit') ?? 20)
const qName = query.get ('name') ?? ''
const qCategory = (query.get ('category') || null) as Category | null
const qPostCountGTE = Number (query.get ('post_count_gte') ?? 1)
const qPostCountLTERaw = query.get ('post_count_lte')
const qPostCountLTE = qPostCountLTERaw ? Number (qPostCountLTERaw) : null
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') || 'post_count:desc') as FetchTagsOrder
const [name, setName] = useState ('')
const [category, setCategory] = useState<Category | null> (null)
const [postCountGTE, setPostCountGTE] = useState (1)
const [postCountLTE, setPostCountLTE] = useState<number | null> (null)
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
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)
setCategory (qCategory)
setPostCountGTE (qPostCountGTE)
setPostCountLTE (qPostCountLTE)
setCreatedFrom (qCreatedFrom)
setCreatedTo (qCreatedTo)
setUpdatedFrom (qUpdatedFrom)
setUpdatedTo (qUpdatedTo)
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 (
<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={name}
onChange={e => setName (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* カテゴリ */}
<div>
<Label></Label>
<select
value={category ?? ''}
onChange={e => setCategory((e.target.value || null) as Category | null)}
className="w-full border p-2 rounded">
<option value="">&nbsp;</option>
{CATEGORIES.map (cat => (
<option key={cat} value={cat}>
{CATEGORY_NAMES[cat]}
</option>))}
</select>
</div>
{/* 広場の投稿数 */}
<div>
<Label>稿</Label>
<input
type="number"
min="0"
value={postCountGTE < 0 ? 0 : String (postCountGTE)}
onChange={e => setPostCountGTE (Number (e.target.value || 0))}
className="border rounded p-2"/>
<span className="mx-1"></span>
<input
type="number"
min="0"
value={postCountLTE == null ? '' : String (postCountLTE)}
onChange={e => setPostCountLTE (e.target.value ? Number (e.target.value) : null)}
className="border rounded p-2"/>
</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-72"/>
<col className="w-48"/>
<col className="w-16"/>
<col className="w-44"/>
<col className="w-44"/>
<col className="w-16"/>
</colgroup>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="name"
label="タグ"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="category"
label="カテゴリ"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="post_count"
label="件数"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="created_at"
label="最初の記載日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="updated_at"
label="更新日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap"/>
</tr>
</thead>
<tbody>
{results.map (row => (
<tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<TagLink tag={row} withCount={false}/>
</td>
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
<td className="p-2 text-right">{row.postCount}</td>
<td className="p-2">{dateString (row.createdAt)}</td>
<td className="p-2">{dateString (row.updatedAt)}</td>
<td className="p-2">
<PrefetchLink to={`/posts/changes?tag=${ row.id }`}>
</PrefetchLink>
</td>
</tr>))}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages}/>
</div>) : '結果ないよ(笑)')}
</MainArea>)
}) satisfies FC