This commit is contained in:
@@ -19,6 +19,7 @@ import PostNewPage from '@/pages/posts/PostNewPage'
|
|||||||
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
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 TagListPage from '@/pages/tags/TagListPage'
|
||||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||||
import WikiDiffPage from '@/pages/wiki/WikiDiffPage'
|
import WikiDiffPage from '@/pages/wiki/WikiDiffPage'
|
||||||
import WikiEditPage from '@/pages/wiki/WikiEditPage'
|
import WikiEditPage from '@/pages/wiki/WikiEditPage'
|
||||||
@@ -46,6 +47,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
<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" element={<TagListPage/>}/>
|
||||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import SectionTitle from '@/components/common/SectionTitle'
|
|||||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
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, CATEGORY_NAMES } 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 { dateString, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -255,15 +255,6 @@ export default (({ post }: Props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryNames: Record<Category, string> = {
|
|
||||||
deerjikist: 'ニジラー',
|
|
||||||
meme: '原作・ネタ元・ミーム等',
|
|
||||||
character: 'キャラクター',
|
|
||||||
general: '一般',
|
|
||||||
material: '素材',
|
|
||||||
meta: 'メタタグ',
|
|
||||||
nico: 'ニコニコタグ' }
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(post))
|
if (!(post))
|
||||||
return
|
return
|
||||||
@@ -317,7 +308,7 @@ export default (({ post }: Props) => {
|
|||||||
<motion.div key={post?.id ?? 0} layout>
|
<motion.div key={post?.id ?? 0} layout>
|
||||||
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
|
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
|
||||||
<motion.div layout className="my-3" key={cat}>
|
<motion.div layout className="my-3" key={cat}>
|
||||||
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
|
<SubsectionTitle>{CATEGORY_NAMES[cat]}</SubsectionTitle>
|
||||||
|
|
||||||
<motion.ul layout>
|
<motion.ul layout>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default (({ user }: Props) => {
|
|||||||
{ 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: true },
|
||||||
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
||||||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
||||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ export const CATEGORIES = [
|
|||||||
'nico',
|
'nico',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export const CATEGORY_NAMES: Record<Category, string> = {
|
||||||
|
deerjikist: 'ニジラー',
|
||||||
|
meme: '原作・ネタ元・ミーム等',
|
||||||
|
character: 'キャラクター',
|
||||||
|
general: '一般',
|
||||||
|
material: '素材',
|
||||||
|
meta: 'メタタグ',
|
||||||
|
nico: 'ニコニコタグ',
|
||||||
|
} as const
|
||||||
|
|
||||||
export const FETCH_POSTS_ORDER_FIELDS = [
|
export const FETCH_POSTS_ORDER_FIELDS = [
|
||||||
'title',
|
'title',
|
||||||
'url',
|
'url',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { FetchPostsParams, Post, PostTagChange } from '@/types'
|
|||||||
|
|
||||||
export const fetchPosts = async (
|
export const fetchPosts = async (
|
||||||
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
|
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
|
||||||
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams
|
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
posts: Post[]
|
posts: Post[]
|
||||||
count: number }> =>
|
count: number }> =>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const postsKeys = {
|
|||||||
|
|
||||||
export const tagsKeys = {
|
export const tagsKeys = {
|
||||||
root: ['tags'] as const,
|
root: ['tags'] as const,
|
||||||
|
index: (p) => ['tags', 'index', p] as const,
|
||||||
show: (name: string) => ['tags', name] as const }
|
show: (name: string) => ['tags', name] as const }
|
||||||
|
|
||||||
export const wikiKeys = {
|
export const wikiKeys = {
|
||||||
|
|||||||
@@ -3,6 +3,22 @@ import { apiGet } from '@/lib/api'
|
|||||||
import type { Tag } from '@/types'
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchTags = async (
|
||||||
|
{ name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||||
|
updatedFrom, updatedTo },
|
||||||
|
): Promise<{ tags: Tag[]
|
||||||
|
count: number }> =>
|
||||||
|
await apiGet ('/tags', { params: {
|
||||||
|
...(name && { name }),
|
||||||
|
...(category && { category }),
|
||||||
|
...(postCountGTE && { post_count_gte: postCountGTE }),
|
||||||
|
...(postCountLTE && { post_count_lte: postCountLTE }),
|
||||||
|
...(createdFrom && { created_from: createdFrom }),
|
||||||
|
...(createdTo && { created_to: createdTo }),
|
||||||
|
...(updatedFrom && { updated_from: updatedFrom }),
|
||||||
|
...(updatedTo && { updated_to: updatedTo }) } })
|
||||||
|
|
||||||
|
|
||||||
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,16 +47,16 @@ export const originalCreatedAtString = (
|
|||||||
|
|
||||||
if (from.getHours () === 0 && before.getHours () === 0)
|
if (from.getHours () === 0 && before.getHours () === 0)
|
||||||
{
|
{
|
||||||
if (Math.abs (diff - 86_400_000) < 60_000)
|
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000)
|
||||||
return dateString (from, 'hour') + ' (時刻不詳)'
|
return dateString (from, 'hour') + ' (時刻不詳)'
|
||||||
|
|
||||||
if (from.getDate () === 1 && before.getDate () === 1)
|
if (from.getDate () === 1 && before.getDate () === 1)
|
||||||
{
|
{
|
||||||
if (2_419_200_000 /* 28 日 */ <= diff && diff < 2_764_800_000 /* 32 日 */)
|
if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */)
|
||||||
return dateString (from, 'day') + ' (日不詳)'
|
return dateString (from, 'day') + ' (日不詳)'
|
||||||
|
|
||||||
if (from.getMonth () === 0 && before.getMonth () === 0
|
if (from.getMonth () === 0 && before.getMonth () === 0
|
||||||
&& (31_536_000_000 /* 365 日 */ <= diff
|
&& (31_449_600_000 /* 364 日 */ <= diff
|
||||||
&& diff < 31_708_800_000 /* 367 日 */))
|
&& diff < 31_708_800_000 /* 367 日 */))
|
||||||
return dateString (from, 'month') + ' (月日不詳)'
|
return dateString (from, 'month') + ' (月日不詳)'
|
||||||
}
|
}
|
||||||
@@ -65,9 +65,9 @@ export const originalCreatedAtString = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rtn = ([from ? `${ dateString (from, 'second') }` : '',
|
const rtn = ([from ? `${ dateString (from, 'second') }` : '',
|
||||||
'~',
|
'〜',
|
||||||
before ? `${ dateString (new Date (before.getTime () - 60_000), 'second') }` : '']
|
before ? `${ dateString (new Date (before.getTime () - 60_000), 'second') }` : '']
|
||||||
.filter (Boolean)
|
.filter (Boolean)
|
||||||
.join (' '))
|
.join (' '))
|
||||||
return rtn === '~' ? '年月日不詳' : rtn
|
return rtn === '〜' ? '年月日不詳' : rtn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export default (() => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{results.map (row => (
|
{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">
|
<td className="p-2">
|
||||||
<PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
|
<PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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 DateTimeField from '@/components/common/DateTimeField'
|
||||||
|
import Label from '@/components/common/Label'
|
||||||
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
|
import MainArea from '@/components/layout/MainArea'
|
||||||
|
import { SITE_TITLE } from '@/config'
|
||||||
|
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
|
||||||
|
import { tagsKeys } from '@/lib/queryKeys'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { Category, FetchTagsOrder } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
|
export default (() => {
|
||||||
|
const location = useLocation ()
|
||||||
|
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 qPostCountLTE = Number (query.get ('post_count_lte') ?? (-1))
|
||||||
|
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 (-1)
|
||||||
|
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 = { name: qName, category: qCategory }
|
||||||
|
const { data, isLoading: loading } = useQuery ({
|
||||||
|
queryKey: tagsKeys.index (keys),
|
||||||
|
queryFn: () => fetchTags (keys) })
|
||||||
|
const results = data?.tags ?? []
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setName (qName)
|
||||||
|
setCategory (qCategory)
|
||||||
|
setPostCountGTE (qPostCountGTE)
|
||||||
|
setPostCountLTE (qPostCountLTE)
|
||||||
|
setCreatedFrom (qCreatedFrom)
|
||||||
|
setCreatedTo (qCreatedTo)
|
||||||
|
setUpdatedFrom (qUpdatedFrom)
|
||||||
|
setUpdatedTo (qUpdatedTo)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}
|
||||||
|
className="w-full border p-2 rounded">
|
||||||
|
<option value=""> </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 ? '' : String (postCountGTE)}
|
||||||
|
onChange={e => setPostCountGTE (Number (e.target.value || (-1)))}
|
||||||
|
className="border rounded p-2"/>
|
||||||
|
<span className="mx-1">〜</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={postCountLTE < 0 ? '' : String (postCountLTE)}
|
||||||
|
onChange={e => setPostCountLTE (Number (e.target.value || (-1)))}
|
||||||
|
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"/>
|
||||||
|
</colgroup>
|
||||||
|
|
||||||
|
<thead className="border-b-2 border-black dark:border-white">
|
||||||
|
<tr>
|
||||||
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
|
<SortHeader by="name" label="タグ"/>
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
|
<SortHeader by="category" label="カテゴリ"/>
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
|
<SortHeader by="post_count" 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">
|
||||||
|
<TagLink tag={row} withCount={false}/>
|
||||||
|
</td>
|
||||||
|
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
|
||||||
|
<td className="p-2">{dateString (row.postCount)}</td>
|
||||||
|
<td className="p-2">{dateString (row.createdAt)}</td>
|
||||||
|
<td className="p-2">{dateString (row.updatedAt)}</td>
|
||||||
|
</tr>))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>) : '結果ないよ(笑)')}
|
||||||
|
</MainArea>)
|
||||||
|
}) satisfies FC
|
||||||
@@ -26,6 +26,15 @@ export type FetchPostsParams = {
|
|||||||
limit: number
|
limit: number
|
||||||
order: FetchPostsOrder }
|
order: FetchPostsOrder }
|
||||||
|
|
||||||
|
export type FetchTagsOrder = `${ FetchTagsOrderField }:${ 'asc' | 'desc' }`
|
||||||
|
|
||||||
|
export type FetchTagsOrderField =
|
||||||
|
| 'name'
|
||||||
|
| 'category'
|
||||||
|
| 'post_count'
|
||||||
|
| 'create_at'
|
||||||
|
| 'updated_at'
|
||||||
|
|
||||||
export type Menu = MenuItem[]
|
export type Menu = MenuItem[]
|
||||||
|
|
||||||
export type MenuItem = {
|
export type MenuItem = {
|
||||||
|
|||||||
Reference in New Issue
Block a user