このコミットが含まれているのは:
@@ -2,10 +2,12 @@ import { useLocation } from 'react-router-dom'
|
|||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
|
||||||
export default <T extends string,>({ by, label, currentOrder, defaultDirection }: {
|
export default <T extends string,>({ by, label, currentOrder, defaultDirection }: {
|
||||||
by: T
|
by: T
|
||||||
label: string
|
label: ReactNode
|
||||||
currentOrder: `${ T }:${ 'asc' | 'desc' }`
|
currentOrder: `${ T }:${ 'asc' | 'desc' }`
|
||||||
defaultDirection: Record<T, 'asc' | 'desc'> }) => {
|
defaultDirection: Record<T, 'asc' | 'desc'> }) => {
|
||||||
const [fld, dir] = currentOrder.split (':')
|
const [fld, dir] = currentOrder.split (':')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
|
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
|
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
|
||||||
@@ -67,6 +68,10 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
style,
|
style,
|
||||||
title,
|
title,
|
||||||
...props }) => {
|
...props }) => {
|
||||||
|
const { message } = useUITranslation ()
|
||||||
|
const missingMaterial = message ('tagLink.missingMaterial', { name: tag.name })
|
||||||
|
const missingDeerjikist = message ('tagLink.missingDeerjikist', { name: tag.name })
|
||||||
|
const missingWiki = message ('tagLink.missingWiki', { name: tag.name })
|
||||||
const colourStyle = {
|
const colourStyle = {
|
||||||
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
|
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
|
||||||
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`,
|
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`,
|
||||||
@@ -121,7 +126,8 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}
|
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}
|
||||||
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
||||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
||||||
title={`${ tag.name } 素材情報が存在しません.`}>
|
lang={missingMaterial.language}
|
||||||
|
title={missingMaterial.text}>
|
||||||
!
|
!
|
||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
: (
|
: (
|
||||||
@@ -131,7 +137,8 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
to={`/tags/${ tag.id }/deerjikists`}
|
to={`/tags/${ tag.id }/deerjikists`}
|
||||||
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
||||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
||||||
title={`${ tag.name } に関する情報が存在しません.`}>
|
lang={missingDeerjikist.language}
|
||||||
|
title={missingDeerjikist.text}>
|
||||||
!
|
!
|
||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
: (
|
: (
|
||||||
@@ -139,7 +146,8 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||||
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
||||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
||||||
title={`${ tag.name } Wiki が存在しません.`}>
|
lang={missingWiki.language}
|
||||||
|
title={missingWiki.text}>
|
||||||
!
|
!
|
||||||
</PrefetchLink>)))}
|
</PrefetchLink>)))}
|
||||||
</span>)}
|
</span>)}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
import LocalisedText from '@/components/LocalisedText'
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
messageKey: string
|
messageKey: string
|
||||||
@@ -13,12 +11,11 @@ type Props = {
|
|||||||
const TranslatedText = (
|
const TranslatedText = (
|
||||||
{ messageKey, namespace = 'navigation', values, className }: Props,
|
{ messageKey, namespace = 'navigation', values, className }: Props,
|
||||||
) => {
|
) => {
|
||||||
const locale = useLocale ()
|
const { message } = useUITranslation (namespace)
|
||||||
const { t } = useTranslation (namespace, { lng: locale })
|
const result = message (messageKey, values)
|
||||||
const result = t (messageKey, { ...values, returnDetails: true })
|
|
||||||
return (
|
return (
|
||||||
<LocalisedText language={result.usedLng} className={className}>
|
<LocalisedText language={result.language} className={className}>
|
||||||
{result.res}
|
{result.text}
|
||||||
</LocalisedText>)
|
</LocalisedText>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ const getPages = (
|
|||||||
|
|
||||||
const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
const { message } = useUITranslation ()
|
||||||
|
|
||||||
const buildTo = (p: number) => {
|
const buildTo = (p: number) => {
|
||||||
const qs = new URLSearchParams (location.search)
|
const qs = new URLSearchParams (location.search)
|
||||||
@@ -59,7 +61,9 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
|||||||
const pages = getPages (page, totalPages, siblingCount)
|
const pages = getPages (page, totalPages, siblingCount)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="mt-4 flex justify-center" aria-label="Pagination">
|
<nav className="mt-4 flex justify-center"
|
||||||
|
lang={message ('pagination.label').language}
|
||||||
|
aria-label={message ('pagination.label').text}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(page > 1)
|
{(page > 1)
|
||||||
? (
|
? (
|
||||||
@@ -67,13 +71,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
|||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
className="md:hidden p-2"
|
className="md:hidden p-2"
|
||||||
to={buildTo (1)}
|
to={buildTo (1)}
|
||||||
aria-label="最初のページ">
|
lang={message ('pagination.first').language}
|
||||||
|
aria-label={message ('pagination.first').text}>
|
||||||
|<
|
|<
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
className="p-2"
|
className="p-2"
|
||||||
to={buildTo (page - 1)}
|
to={buildTo (page - 1)}
|
||||||
aria-label="前のページ">
|
lang={message ('pagination.previous').language}
|
||||||
|
aria-label={message ('pagination.previous').text}>
|
||||||
<
|
<
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</>)
|
</>)
|
||||||
@@ -106,13 +112,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
|||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
className="p-2"
|
className="p-2"
|
||||||
to={buildTo (page + 1)}
|
to={buildTo (page + 1)}
|
||||||
aria-label="次のページ">
|
lang={message ('pagination.next').language}
|
||||||
|
aria-label={message ('pagination.next').text}>
|
||||||
>
|
>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
className="md:hidden p-2"
|
className="md:hidden p-2"
|
||||||
to={buildTo (totalPages)}
|
to={buildTo (totalPages)}
|
||||||
aria-label="最後のページ">
|
lang={message ('pagination.last').language}
|
||||||
|
aria-label={message ('pagination.last').text}>
|
||||||
>|
|
>|
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</>)
|
</>)
|
||||||
|
|||||||
+36
-6
@@ -2,12 +2,36 @@ import { createInstance } from 'i18next'
|
|||||||
import { initReactI18next } from 'react-i18next'
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
|
||||||
import ainuNavigation from '@/locales/ain-Kana/navigation.json'
|
import ainuNavigation from '@/locales/ain-Kana/navigation.json'
|
||||||
|
import ainuCommon from '@/locales/ain-Kana/common.json'
|
||||||
|
import ainuTags from '@/locales/ain-Kana/tags.json'
|
||||||
import nipponeseNavigation from '@/locales/ja/navigation.json'
|
import nipponeseNavigation from '@/locales/ja/navigation.json'
|
||||||
|
import nipponeseCommon from '@/locales/ja/common.json'
|
||||||
|
import nipponeseTags from '@/locales/ja/tags.json'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
export const i18n = createInstance ()
|
export const i18n = createInstance ()
|
||||||
|
|
||||||
|
|
||||||
|
// Keep Japanese placeholders editable without reporting them as completed translations.
|
||||||
|
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
|
||||||
|
const untranslated = new Set (
|
||||||
|
Array.isArray (resource._untranslated) ? resource._untranslated : [])
|
||||||
|
const select = (entries: Record<string, unknown>, prefix = ''): Record<string, unknown> =>
|
||||||
|
Object.fromEntries (Object.entries (entries).flatMap (([key, value]) => {
|
||||||
|
const path = prefix ? `${ prefix }.${ key }` : key
|
||||||
|
if (key === '_untranslated' || untranslated.has (path))
|
||||||
|
return []
|
||||||
|
const translated =
|
||||||
|
(value != null && typeof value === 'object' && !(Array.isArray (value)))
|
||||||
|
? select (value as Record<string, unknown>, path)
|
||||||
|
: value
|
||||||
|
return [[key, translated]]
|
||||||
|
}))
|
||||||
|
return select (resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void i18n.use (initReactI18next).init ({
|
void i18n.use (initReactI18next).init ({
|
||||||
lng: 'ja',
|
lng: 'ja',
|
||||||
fallbackLng: 'ja',
|
fallbackLng: 'ja',
|
||||||
@@ -15,10 +39,13 @@ void i18n.use (initReactI18next).init ({
|
|||||||
load: 'currentOnly',
|
load: 'currentOnly',
|
||||||
initAsync: false,
|
initAsync: false,
|
||||||
defaultNS: 'navigation',
|
defaultNS: 'navigation',
|
||||||
ns: ['navigation'],
|
ns: ['navigation', 'common', 'tags'],
|
||||||
resources: {
|
resources: {
|
||||||
ja: { navigation: nipponeseNavigation },
|
ja: { navigation: nipponeseNavigation, common: nipponeseCommon, tags: nipponeseTags },
|
||||||
'ain-Kana': { navigation: ainuNavigation } },
|
'ain-Kana': {
|
||||||
|
navigation: ainuNavigation,
|
||||||
|
common: translatedResource (ainuCommon),
|
||||||
|
tags: translatedResource (ainuTags) } },
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
returnNull: false,
|
returnNull: false,
|
||||||
returnEmptyString: false,
|
returnEmptyString: false,
|
||||||
@@ -50,7 +77,10 @@ export const untranslatedKeys = (
|
|||||||
|
|
||||||
if (import.meta.env.DEV)
|
if (import.meta.env.DEV)
|
||||||
{
|
{
|
||||||
const missing = untranslatedKeys ('ain-Kana')
|
for (const namespace of ['navigation', 'common', 'tags'])
|
||||||
if (missing.length > 0)
|
{
|
||||||
console.warn ('[i18n] ain-Kana: untranslated navigation keys', missing)
|
const missing = untranslatedKeys ('ain-Kana', namespace)
|
||||||
|
if (missing.length > 0)
|
||||||
|
console.warn (`[i18n] ain-Kana: untranslated ${ namespace } keys`, missing)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
|
||||||
|
|
||||||
|
export const useUITranslation = (namespace = 'common') => {
|
||||||
|
const locale = useLocale ()
|
||||||
|
const { t } = useTranslation (namespace, { lng: locale })
|
||||||
|
|
||||||
|
const message = (key: string, values?: Record<string, string | number>) => {
|
||||||
|
const result = t (key, { ...values, returnDetails: true })
|
||||||
|
return { text: result.res, language: result.usedLng }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message }
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { twMerge } from 'tailwind-merge'
|
|||||||
|
|
||||||
import type { ClassValue } from 'clsx'
|
import type { ClassValue } from 'clsx'
|
||||||
|
|
||||||
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
|
|
||||||
export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Date (d) : d
|
export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Date (d) : d
|
||||||
|
|
||||||
@@ -25,6 +27,24 @@ export const dateString = (
|
|||||||
second: unknown == null ? 'numeric' : undefined })
|
second: unknown == null ? 'numeric' : undefined })
|
||||||
|
|
||||||
|
|
||||||
|
export const localisedDateString = (value: string | Date, locale: LocaleCode): string => {
|
||||||
|
if (locale === 'ja')
|
||||||
|
return dateString (value)
|
||||||
|
|
||||||
|
const date = toDate (value)
|
||||||
|
if (Number.isNaN (date.getTime ()))
|
||||||
|
return dateString (value)
|
||||||
|
|
||||||
|
const parts = new Intl.DateTimeFormat ('en-CA', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hourCycle: 'h23' }).formatToParts (date)
|
||||||
|
const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? ''
|
||||||
|
return `${ part ('year') }-${ part ('month') }-${ part ('day') }\u3000${
|
||||||
|
part ('hour') }:${ part ('minute') }:${ part ('second') }`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const originalCreatedAtString = (
|
export const originalCreatedAtString = (
|
||||||
f: string | Date | null,
|
f: string | Date | null,
|
||||||
b: string | Date | null): string => {
|
b: string | Date | null): string => {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"_untranslated": [
|
||||||
|
"countLabel", "categoryLabel", "stateLabel", "loading", "fetchError",
|
||||||
|
"states", "categories.deerjikist", "categories.meme", "categories.character",
|
||||||
|
"categories.general", "categories.meta", "categories.nico", "pagination", "tagLink"
|
||||||
|
],
|
||||||
|
"countLabel": "件数",
|
||||||
|
"categoryLabel": "カテゴリ",
|
||||||
|
"stateLabel": "状態",
|
||||||
|
"search": "イフナラ",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"fetchError": "データ取得エラー",
|
||||||
|
"states": {
|
||||||
|
"active": "有効",
|
||||||
|
"deprecated": "廃止"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"deerjikist": "ニジラー",
|
||||||
|
"meme": "原作・ネタ元・ミーム等",
|
||||||
|
"character": "キャラクター",
|
||||||
|
"general": "一般",
|
||||||
|
"material": "チカㇻペ",
|
||||||
|
"meta": "メタタグ",
|
||||||
|
"nico": "ニコニコタグ"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"label": "Pagination",
|
||||||
|
"first": "最初のページ",
|
||||||
|
"previous": "前のページ",
|
||||||
|
"next": "次のページ",
|
||||||
|
"last": "最後のページ"
|
||||||
|
},
|
||||||
|
"tagLink": {
|
||||||
|
"missingMaterial": "{{name}} 素材情報が存在しません.",
|
||||||
|
"missingDeerjikist": "{{name}} に関する情報が存在しません.",
|
||||||
|
"missingWiki": "{{name}} Wiki が存在しません."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"_untranslated": [
|
||||||
|
"name", "postCount", "firstRecordedAt", "definitionUpdatedAt", "aliases",
|
||||||
|
"parents", "firstRecordedColumn", "updatedAt", "empty"
|
||||||
|
],
|
||||||
|
"title": "タㇰ",
|
||||||
|
"name": "名前",
|
||||||
|
"postCount": "広場の投稿数",
|
||||||
|
"firstRecordedAt": "はじめて記載された日時",
|
||||||
|
"definitionUpdatedAt": "定義の更新日時",
|
||||||
|
"aliases": "別名",
|
||||||
|
"parents": "上位タグ",
|
||||||
|
"firstRecordedColumn": "最初の記載日時",
|
||||||
|
"updatedAt": "更新日時",
|
||||||
|
"history": "フㇱコアンペ",
|
||||||
|
"empty": "結果ないよ(笑)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"countLabel": "件数",
|
||||||
|
"categoryLabel": "カテゴリ",
|
||||||
|
"stateLabel": "状態",
|
||||||
|
"search": "検索",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"fetchError": "データ取得エラー",
|
||||||
|
"states": {
|
||||||
|
"active": "有効",
|
||||||
|
"deprecated": "廃止"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"deerjikist": "ニジラー",
|
||||||
|
"meme": "原作・ネタ元・ミーム等",
|
||||||
|
"character": "キャラクター",
|
||||||
|
"general": "一般",
|
||||||
|
"material": "素材",
|
||||||
|
"meta": "メタタグ",
|
||||||
|
"nico": "ニコニコタグ"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"label": "Pagination",
|
||||||
|
"first": "最初のページ",
|
||||||
|
"previous": "前のページ",
|
||||||
|
"next": "次のページ",
|
||||||
|
"last": "最後のページ"
|
||||||
|
},
|
||||||
|
"tagLink": {
|
||||||
|
"missingMaterial": "{{name}} 素材情報が存在しません.",
|
||||||
|
"missingDeerjikist": "{{name}} に関する情報が存在しません.",
|
||||||
|
"missingWiki": "{{name}} Wiki が存在しません."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"title": "タグ",
|
||||||
|
"name": "名前",
|
||||||
|
"postCount": "広場の投稿数",
|
||||||
|
"firstRecordedAt": "はじめて記載された日時",
|
||||||
|
"definitionUpdatedAt": "定義の更新日時",
|
||||||
|
"aliases": "別名",
|
||||||
|
"parents": "上位タグ",
|
||||||
|
"firstRecordedColumn": "最初の記載日時",
|
||||||
|
"updatedAt": "更新日時",
|
||||||
|
"history": "耕作履歴",
|
||||||
|
"empty": "結果ないよ(笑)"
|
||||||
|
}
|
||||||
@@ -4,16 +4,18 @@ import { Helmet } from 'react-helmet-async'
|
|||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import SortHeader from '@/components/SortHeader'
|
import SortHeader from '@/components/SortHeader'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
|
import TranslatedText from '@/components/TranslatedText'
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import Pagination from '@/components/common/Pagination'
|
import Pagination from '@/components/common/Pagination'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
|
import { CATEGORIES } from '@/consts'
|
||||||
import { clientScrollBehaviour } from '@/lib/clientAnimation'
|
import { clientScrollBehaviour } from '@/lib/clientAnimation'
|
||||||
import { tagsKeys } from '@/lib/queryKeys'
|
import { tagsKeys } from '@/lib/queryKeys'
|
||||||
import {
|
import {
|
||||||
@@ -26,8 +28,9 @@ import {
|
|||||||
} from '@/lib/settings'
|
} from '@/lib/settings'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { fetchTags } from '@/lib/tags'
|
import { fetchTags } from '@/lib/tags'
|
||||||
import { dateString, inputClass } from '@/lib/utils'
|
import { inputClass, localisedDateString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, FormEvent } from 'react'
|
import type { FC, FormEvent } from 'react'
|
||||||
|
|
||||||
@@ -45,8 +48,6 @@ const boolFromQuery = (value: string | null): boolean =>
|
|||||||
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
||||||
|
|
||||||
|
|
||||||
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
|
|
||||||
|
|
||||||
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
|
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
|
||||||
const n = Number (value)
|
const n = Number (value)
|
||||||
return n === 20 || n === 50 || n === 100 ? n : null
|
return n === 20 || n === 50 || n === 100 ? n : null
|
||||||
@@ -75,6 +76,8 @@ const parseOrder = (value: string | null): FetchTagsOrder | null => {
|
|||||||
|
|
||||||
const TagListPage: FC = () => {
|
const TagListPage: FC = () => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const { message } = useUITranslation ('tags')
|
||||||
|
const label = (key: string) => <TranslatedText namespace="tags" messageKey={key}/>
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
@@ -99,7 +102,8 @@ const TagListPage: FC = () => {
|
|||||||
const qCreatedTo = query.get ('created_to') ?? ''
|
const qCreatedTo = query.get ('created_to') ?? ''
|
||||||
const qUpdatedFrom = query.get ('updated_from') ?? ''
|
const qUpdatedFrom = query.get ('updated_from') ?? ''
|
||||||
const qUpdatedTo = query.get ('updated_to') ?? ''
|
const qUpdatedTo = query.get ('updated_to') ?? ''
|
||||||
const qDeprecated = query.has ('deprecated')
|
const qDeprecated =
|
||||||
|
query.has ('deprecated')
|
||||||
? boolFromQuery (query.get ('deprecated'))
|
? boolFromQuery (query.get ('deprecated'))
|
||||||
: null
|
: null
|
||||||
const order = (
|
const order = (
|
||||||
@@ -130,7 +134,7 @@ const TagListPage: FC = () => {
|
|||||||
updatedFrom: qUpdatedFrom,
|
updatedFrom: qUpdatedFrom,
|
||||||
updatedTo: qUpdatedTo,
|
updatedTo: qUpdatedTo,
|
||||||
deprecated: qDeprecated }
|
deprecated: qDeprecated }
|
||||||
const { data, isLoading: loading } = useQuery ({
|
const { data, isLoading: loading, isError } = useQuery ({
|
||||||
queryKey: tagsKeys.index (keys, locale),
|
queryKey: tagsKeys.index (keys, locale),
|
||||||
queryFn: () => fetchTags (keys, locale) })
|
queryFn: () => fetchTags (keys, locale) })
|
||||||
const results = data?.tags ?? []
|
const results = data?.tags ?? []
|
||||||
@@ -203,32 +207,28 @@ const TagListPage: FC = () => {
|
|||||||
|
|
||||||
useKeyboardShortcuts (useMemo (() => ({
|
useKeyboardShortcuts (useMemo (() => ({
|
||||||
...(page > 1
|
...(page > 1
|
||||||
? {
|
? { 'pagination.previous': () => {
|
||||||
'pagination.previous': () => {
|
|
||||||
updatePage (page - 1)
|
updatePage (page - 1)
|
||||||
},
|
} }
|
||||||
}
|
|
||||||
: { }),
|
: { }),
|
||||||
...(totalPages > 0 && page < totalPages
|
...(totalPages > 0 && page < totalPages
|
||||||
? {
|
? { 'pagination.next': () => {
|
||||||
'pagination.next': () => {
|
|
||||||
updatePage (Math.min (page + 1, totalPages))
|
updatePage (Math.min (page + 1, totalPages))
|
||||||
},
|
} }
|
||||||
}
|
|
||||||
: { }),
|
: { }),
|
||||||
}), [page, totalPages, location.search]))
|
}), [page, totalPages, location.search]))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>タグ | {SITE_TITLE}</title>
|
<title>{message ('title').text} | {SITE_TITLE}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<PageTitle>タグ</PageTitle>
|
<PageTitle>{label ('title')}</PageTitle>
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>件数</span>
|
<span>{label ('common:countLabel')}</span>
|
||||||
<select
|
<select
|
||||||
value={limit}
|
value={limit}
|
||||||
onChange={ev => updateListQuery ({
|
onChange={ev => updateListQuery ({
|
||||||
@@ -246,7 +246,7 @@ const TagListPage: FC = () => {
|
|||||||
|
|
||||||
<form onSubmit={handleSearch} className="space-y-2">
|
<form onSubmit={handleSearch} className="space-y-2">
|
||||||
{/* 名前 */}
|
{/* 名前 */}
|
||||||
<FormField label="名前">
|
<FormField label={label ('name')}>
|
||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -257,7 +257,7 @@ const TagListPage: FC = () => {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* カテゴリ */}
|
{/* カテゴリ */}
|
||||||
<FormField label="カテゴリ">
|
<FormField label={label ('common:categoryLabel')}>
|
||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
<select
|
<select
|
||||||
value={category ?? ''}
|
value={category ?? ''}
|
||||||
@@ -265,14 +265,15 @@ const TagListPage: FC = () => {
|
|||||||
className={inputClass (invalid)}>
|
className={inputClass (invalid)}>
|
||||||
<option value=""> </option>
|
<option value=""> </option>
|
||||||
{CATEGORIES.map (cat => (
|
{CATEGORIES.map (cat => (
|
||||||
<option key={cat} value={cat}>
|
<option key={cat} value={cat}
|
||||||
{CATEGORY_NAMES[cat]}
|
lang={message (`common:categories.${ cat }`).language}>
|
||||||
|
{message (`common:categories.${ cat }`).text}
|
||||||
</option>))}
|
</option>))}
|
||||||
</select>)}
|
</select>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* 広場の投稿数 */}
|
{/* 広場の投稿数 */}
|
||||||
<FormField label="広場の投稿数">
|
<FormField label={label ('postCount')}>
|
||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -294,34 +295,38 @@ const TagListPage: FC = () => {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* はじめて記載された日時 */}
|
{/* はじめて記載された日時 */}
|
||||||
<FormField label="はじめて記載された日時">
|
<FormField label={label ('firstRecordedAt')}>
|
||||||
{() => (
|
{() => (
|
||||||
<>
|
<>
|
||||||
<DateTimeField
|
<DateTimeField
|
||||||
|
lang={locale}
|
||||||
value={createdFrom ?? undefined}
|
value={createdFrom ?? undefined}
|
||||||
onChange={setCreatedFrom}/>
|
onChange={setCreatedFrom}/>
|
||||||
<span className="mx-1">〜</span>
|
<span className="mx-1">〜</span>
|
||||||
<DateTimeField
|
<DateTimeField
|
||||||
|
lang={locale}
|
||||||
value={createdTo ?? undefined}
|
value={createdTo ?? undefined}
|
||||||
onChange={setCreatedTo}/>
|
onChange={setCreatedTo}/>
|
||||||
</>)}
|
</>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* 定義の更新日時 */}
|
{/* 定義の更新日時 */}
|
||||||
<FormField label="定義の更新日時">
|
<FormField label={label ('definitionUpdatedAt')}>
|
||||||
{() => (
|
{() => (
|
||||||
<>
|
<>
|
||||||
<DateTimeField
|
<DateTimeField
|
||||||
|
lang={locale}
|
||||||
value={updatedFrom ?? undefined}
|
value={updatedFrom ?? undefined}
|
||||||
onChange={setUpdatedFrom}/>
|
onChange={setUpdatedFrom}/>
|
||||||
<span className="mx-1">〜</span>
|
<span className="mx-1">〜</span>
|
||||||
<DateTimeField
|
<DateTimeField
|
||||||
|
lang={locale}
|
||||||
value={updatedTo ?? undefined}
|
value={updatedTo ?? undefined}
|
||||||
onChange={setUpdatedTo}/>
|
onChange={setUpdatedTo}/>
|
||||||
</>)}
|
</>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="状態">
|
<FormField label={label ('common:stateLabel')}>
|
||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
<select
|
<select
|
||||||
value={deprecated == null ? '' : (deprecated ? '1' : '0')}
|
value={deprecated == null ? '' : (deprecated ? '1' : '0')}
|
||||||
@@ -331,8 +336,12 @@ const TagListPage: FC = () => {
|
|||||||
: e.target.value === '1')}
|
: e.target.value === '1')}
|
||||||
className={inputClass (invalid)}>
|
className={inputClass (invalid)}>
|
||||||
<option value=""> </option>
|
<option value=""> </option>
|
||||||
<option value="0">有効</option>
|
<option value="0" lang={message ('common:states.active').language}>
|
||||||
<option value="1">廃止</option>
|
{message ('common:states.active').text}
|
||||||
|
</option>
|
||||||
|
<option value="1" lang={message ('common:states.deprecated').language}>
|
||||||
|
{message ('common:states.deprecated').text}
|
||||||
|
</option>
|
||||||
</select>)}
|
</select>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@@ -340,13 +349,14 @@ const TagListPage: FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-blue-500 text-white px-4 py-2 rounded">
|
className="bg-blue-500 text-white px-4 py-2 rounded">
|
||||||
検索
|
{label ('common:search')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? 'Loading...' : (results.length > 0 ? (
|
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
|
{loading ? label ('common:loading') : (results.length > 0 ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[2000px] table-fixed border-collapse">
|
<table className="w-full min-w-[2000px] table-fixed border-collapse">
|
||||||
@@ -367,38 +377,44 @@ const TagListPage: FC = () => {
|
|||||||
<th className="p-2 text-left whitespace-nowrap">
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
<SortHeader<FetchTagsOrderField>
|
<SortHeader<FetchTagsOrderField>
|
||||||
by="name"
|
by="name"
|
||||||
label="タグ"
|
label={label ('title')}
|
||||||
currentOrder={order}
|
currentOrder={order}
|
||||||
defaultDirection={defaultDirection}/>
|
defaultDirection={defaultDirection}/>
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
<SortHeader<FetchTagsOrderField>
|
<SortHeader<FetchTagsOrderField>
|
||||||
by="post_count"
|
by="post_count"
|
||||||
label="件数"
|
label={label ('common:countLabel')}
|
||||||
currentOrder={order}
|
currentOrder={order}
|
||||||
defaultDirection={defaultDirection}/>
|
defaultDirection={defaultDirection}/>
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
<SortHeader<FetchTagsOrderField>
|
<SortHeader<FetchTagsOrderField>
|
||||||
by="category"
|
by="category"
|
||||||
label="カテゴリ"
|
label={label ('common:categoryLabel')}
|
||||||
currentOrder={order}
|
currentOrder={order}
|
||||||
defaultDirection={defaultDirection}/>
|
defaultDirection={defaultDirection}/>
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">状態</th>
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
<th className="p-2 text-left whitespace-nowrap">別名</th>
|
{label ('common:stateLabel')}
|
||||||
<th className="p-2 text-left whitespace-nowrap">上位タグ</th>
|
</th>
|
||||||
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
|
{label ('aliases')}
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
|
{label ('parents')}
|
||||||
|
</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
<SortHeader<FetchTagsOrderField>
|
<SortHeader<FetchTagsOrderField>
|
||||||
by="created_at"
|
by="created_at"
|
||||||
label="最初の記載日時"
|
label={label ('firstRecordedColumn')}
|
||||||
currentOrder={order}
|
currentOrder={order}
|
||||||
defaultDirection={defaultDirection}/>
|
defaultDirection={defaultDirection}/>
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
<SortHeader<FetchTagsOrderField>
|
<SortHeader<FetchTagsOrderField>
|
||||||
by="updated_at"
|
by="updated_at"
|
||||||
label="更新日時"
|
label={label ('updatedAt')}
|
||||||
currentOrder={order}
|
currentOrder={order}
|
||||||
defaultDirection={defaultDirection}/>
|
defaultDirection={defaultDirection}/>
|
||||||
</th>
|
</th>
|
||||||
@@ -420,8 +436,12 @@ const TagListPage: FC = () => {
|
|||||||
withCount={false}/>
|
withCount={false}/>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 text-right">{row.postCount}</td>
|
<td className="p-2 text-right">{row.postCount}</td>
|
||||||
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
|
<td className="p-2">
|
||||||
<td className="p-2">{tagStateLabel (row.deprecatedAt)}</td>
|
{label (`common:categories.${ row.category }`)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
{row.deprecatedAt ? label ('common:states.deprecated') : ''}
|
||||||
|
</td>
|
||||||
<td className="p-2">{row.aliases.join (' ')}</td>
|
<td className="p-2">{row.aliases.join (' ')}</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{row.parents.map (t => (
|
{row.parents.map (t => (
|
||||||
@@ -432,14 +452,26 @@ const TagListPage: FC = () => {
|
|||||||
withCount={false}/>
|
withCount={false}/>
|
||||||
</span>))}
|
</span>))}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">{dateString (row.createdAt)}</td>
|
<td className="p-2">
|
||||||
<td className="p-2">{dateString (row.updatedAt)}</td>
|
<time dateTime={row.createdAt} lang={locale}>
|
||||||
|
<LocalisedText language={locale}>
|
||||||
|
{localisedDateString (row.createdAt, locale)}
|
||||||
|
</LocalisedText>
|
||||||
|
</time>
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<time dateTime={row.updatedAt} lang={locale}>
|
||||||
|
<LocalisedText language={locale}>
|
||||||
|
{localisedDateString (row.updatedAt, locale)}
|
||||||
|
</LocalisedText>
|
||||||
|
</time>
|
||||||
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={row.category === 'nico'
|
to={row.category === 'nico'
|
||||||
? `/posts/changes?external_tag=${ row.id }`
|
? `/posts/changes?external_tag=${ row.id }`
|
||||||
: `/posts/changes?tag=${ row.id }`}>
|
: `/posts/changes?tag=${ row.id }`}>
|
||||||
耕作履歴
|
{label ('history')}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</td>
|
</td>
|
||||||
</tr>))}
|
</tr>))}
|
||||||
@@ -448,7 +480,7 @@ const TagListPage: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination page={page} totalPages={totalPages}/>
|
<Pagination page={page} totalPages={totalPages}/>
|
||||||
</div>) : '結果ないよ(笑)')}
|
</div>) : (isError ? null : label ('empty')))}
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする