| @@ -19,6 +19,7 @@ import PostNewPage from '@/pages/posts/PostNewPage' | |||
| import PostSearchPage from '@/pages/posts/PostSearchPage' | |||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | |||
| import SettingPage from '@/pages/users/SettingPage' | |||
| import TagListPage from '@/pages/tags/TagListPage' | |||
| import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | |||
| import WikiDiffPage from '@/pages/wiki/WikiDiffPage' | |||
| import WikiEditPage from '@/pages/wiki/WikiEditPage' | |||
| @@ -46,6 +47,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||
| <Route path="/posts/search" element={<PostSearchPage/>}/> | |||
| <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | |||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | |||
| <Route path="/tags" element={<TagListPage/>}/> | |||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | |||
| <Route path="/wiki" element={<WikiSearchPage/>}/> | |||
| <Route path="/wiki/:title" element={<WikiDetailPage/>}/> | |||
| @@ -17,7 +17,7 @@ import SectionTitle from '@/components/common/SectionTitle' | |||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | |||
| import SidebarComponent from '@/components/layout/SidebarComponent' | |||
| 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 { 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 (() => { | |||
| if (!(post)) | |||
| return | |||
| @@ -317,7 +308,7 @@ export default (({ post }: Props) => { | |||
| <motion.div key={post?.id ?? 0} layout> | |||
| {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( | |||
| <motion.div layout className="my-3" key={cat}> | |||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||
| <SubsectionTitle>{CATEGORY_NAMES[cat]}</SubsectionTitle> | |||
| <motion.ul layout> | |||
| <AnimatePresence initial={false}> | |||
| @@ -74,7 +74,7 @@ export default (({ user }: Props) => { | |||
| { name: '履歴', to: '/posts/changes' }, | |||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | |||
| { name: 'タグ', to: '/tags', subMenu: [ | |||
| { name: 'タグ一覧', to: '/tags', visible: false }, | |||
| { name: 'タグ一覧', to: '/tags', visible: true }, | |||
| { name: '別名タグ', to: '/tags/aliases', visible: false }, | |||
| { name: '上位タグ', to: '/tags/implications', visible: false }, | |||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | |||
| @@ -13,6 +13,16 @@ export const CATEGORIES = [ | |||
| 'nico', | |||
| ] as const | |||
| export const CATEGORY_NAMES: Record<Category, string> = { | |||
| deerjikist: 'ニジラー', | |||
| meme: '原作・ネタ元・ミーム等', | |||
| character: 'キャラクター', | |||
| general: '一般', | |||
| material: '素材', | |||
| meta: 'メタタグ', | |||
| nico: 'ニコニコタグ', | |||
| } as const | |||
| export const FETCH_POSTS_ORDER_FIELDS = [ | |||
| 'title', | |||
| 'url', | |||
| @@ -5,7 +5,7 @@ import type { FetchPostsParams, Post, PostTagChange } from '@/types' | |||
| export const fetchPosts = async ( | |||
| { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo, | |||
| originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams | |||
| originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams, | |||
| ): Promise<{ | |||
| posts: Post[] | |||
| count: number }> => | |||
| @@ -10,6 +10,7 @@ export const postsKeys = { | |||
| export const tagsKeys = { | |||
| root: ['tags'] as const, | |||
| index: (p) => ['tags', 'index', p] as const, | |||
| show: (name: string) => ['tags', name] as const } | |||
| export const wikiKeys = { | |||
| @@ -3,6 +3,22 @@ import { apiGet } from '@/lib/api' | |||
| 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> => { | |||
| try | |||
| { | |||
| @@ -47,16 +47,16 @@ export const originalCreatedAtString = ( | |||
| 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') + ' (時刻不詳)' | |||
| 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') + ' (日不詳)' | |||
| 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 日 */)) | |||
| return dateString (from, 'month') + ' (月日不詳)' | |||
| } | |||
| @@ -65,9 +65,9 @@ export const originalCreatedAtString = ( | |||
| } | |||
| const rtn = ([from ? `${ dateString (from, 'second') }` : '', | |||
| '~', | |||
| '〜', | |||
| before ? `${ dateString (new Date (before.getTime () - 60_000), 'second') }` : ''] | |||
| .filter (Boolean) | |||
| .join (' ')) | |||
| return rtn === '~' ? '年月日不詳' : rtn | |||
| return rtn === '〜' ? '年月日不詳' : rtn | |||
| } | |||
| @@ -358,7 +358,7 @@ export default (() => { | |||
| </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 | |||
| @@ -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 | |||
| 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 MenuItem = { | |||