| @@ -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 = { | ||||