| @@ -75,7 +75,7 @@ class PostsController < ApplicationController | |||
| else | |||
| "posts.#{ order[0] }" | |||
| 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) | |||
| .offset(offset) | |||
| .to_a | |||
| @@ -2,18 +2,71 @@ class TagsController < ApplicationController | |||
| def index | |||
| 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? | |||
| Tag.joins(:posts, :tag_name) | |||
| else | |||
| Tag.joins(:tag_name) | |||
| end | |||
| .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 | |||
| def autocomplete | |||
| @@ -2,7 +2,8 @@ | |||
| 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 | |||
| @@ -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 buildTo = (p: number) => { | |||
| @@ -63,19 +63,31 @@ export default (({ page, totalPages, siblingCount = 4 }) => { | |||
| <nav className="mt-4 flex justify-center" aria-label="Pagination"> | |||
| <div className="flex items-center gap-2"> | |||
| {(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) => ( | |||
| (p === '…') | |||
| ? <span key={`dots-${ idx }`}>…</span> | |||
| ? <span key={`dots-${ idx }`} className="p-2">…</span> | |||
| : ((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) | |||
| ? <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> | |||
| </nav>) | |||
| }) satisfies FC<Props> | |||
| @@ -1,4 +1,4 @@ | |||
| import type { FetchPostsParams } from '@/types' | |||
| import type { FetchPostsParams, FetchTagsParams } from '@/types' | |||
| export const postsKeys = { | |||
| root: ['posts'] as const, | |||
| @@ -9,9 +9,9 @@ export const postsKeys = { | |||
| ['posts', 'changes', p] as const } | |||
| 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 = { | |||
| root: ['wiki'] as const, | |||
| @@ -1,22 +1,26 @@ | |||
| import { apiGet } from '@/lib/api' | |||
| import type { Tag } from '@/types' | |||
| import type { FetchTagsParams, Tag } from '@/types' | |||
| 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[] | |||
| count: number }> => | |||
| await apiGet ('/tags', { params: { | |||
| ...(post != null && { post }), | |||
| ...(name && { name }), | |||
| ...(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 }), | |||
| ...(createdTo && { created_to: createdTo }), | |||
| ...(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> => { | |||
| @@ -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,20 +324,40 @@ 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> | |||
| @@ -1,23 +1,38 @@ | |||
| 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 { useLocation, useNavigate } from 'react-router-dom' | |||
| 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 } 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 (() => { | |||
| const location = useLocation () | |||
| const navigate = useNavigate () | |||
| const query = useMemo (() => new URLSearchParams (location.search), [location.search]) | |||
| const page = Number (query.get ('page') ?? 1) | |||
| @@ -26,7 +41,8 @@ export default (() => { | |||
| 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 qPostCountLTE = | |||
| query.get ('post_count_lte') ? Number (query.get ('post_count_lte')) : null | |||
| const qCreatedFrom = query.get ('created_from') ?? '' | |||
| const qCreatedTo = query.get ('created_to') ?? '' | |||
| const qUpdatedFrom = query.get ('updated_from') ?? '' | |||
| @@ -36,17 +52,28 @@ export default (() => { | |||
| const [name, setName] = useState ('') | |||
| const [category, setCategory] = useState<Category | null> (null) | |||
| 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 [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 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) | |||
| @@ -57,12 +84,36 @@ export default (() => { | |||
| setCreatedTo (qCreatedTo) | |||
| setUpdatedFrom (qUpdatedFrom) | |||
| 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 ( | |||
| <MainArea> | |||
| <Helmet> | |||
| @@ -88,7 +139,7 @@ export default (() => { | |||
| <Label>カテゴリ</Label> | |||
| <select | |||
| 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"> | |||
| <option value=""> </option> | |||
| {CATEGORIES.map (cat => ( | |||
| @@ -104,15 +155,15 @@ export default (() => { | |||
| <input | |||
| type="number" | |||
| 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"/> | |||
| <span className="mx-1">〜</span> | |||
| <input | |||
| type="number" | |||
| 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"/> | |||
| </div> | |||
| @@ -165,19 +216,39 @@ export default (() => { | |||
| <thead className="border-b-2 border-black dark:border-white"> | |||
| <tr> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="name" label="タグ"/> | |||
| <SortHeader<FetchTagsOrderField> | |||
| by="name" | |||
| label="タグ" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="category" label="カテゴリ"/> | |||
| <SortHeader<FetchTagsOrderField> | |||
| by="category" | |||
| label="カテゴリ" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <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 className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="created_at" label="最初の記載日時"/> | |||
| <SortHeader<FetchTagsOrderField> | |||
| by="created_at" | |||
| label="最初の記載日時" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| <th className="p-2 text-left whitespace-nowrap"> | |||
| <SortHeader by="updated_at" label="更新日時"/> | |||
| <SortHeader<FetchTagsOrderField> | |||
| by="updated_at" | |||
| label="更新日時" | |||
| currentOrder={order} | |||
| defaultDirection={defaultDirection}/> | |||
| </th> | |||
| </tr> | |||
| </thead> | |||
| @@ -189,13 +260,15 @@ export default (() => { | |||
| <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 text-right">{row.postCount}</td> | |||
| <td className="p-2">{dateString (row.createdAt)}</td> | |||
| <td className="p-2">{dateString (row.updatedAt)}</td> | |||
| </tr>))} | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </div>) : '結果ないよ(笑)')} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| @@ -32,9 +32,23 @@ export type FetchTagsOrderField = | |||
| | 'name' | |||
| | 'category' | |||
| | 'post_count' | |||
| | 'create_at' | |||
| | 'created_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 MenuItem = { | |||
| @@ -80,6 +94,8 @@ export type Tag = { | |||
| name: string | |||
| category: Category | |||
| postCount: number | |||
| createdAt: string | |||
| updatedAt: string | |||
| hasWiki: boolean | |||
| children?: Tag[] | |||
| matchedAlias?: string | null } | |||