このコミットが含まれているのは:
@@ -4,10 +4,11 @@ import type { ReactNode } from 'react'
|
||||
|
||||
type DialogueVariant = 'default' | 'danger'
|
||||
|
||||
type ConfirmOptions = { title: string
|
||||
type ConfirmOptions = { title: ReactNode
|
||||
description?: ReactNode
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
confirmText?: ReactNode
|
||||
cancelText?: ReactNode
|
||||
closeLabel?: ReactNode
|
||||
variant?: DialogueVariant }
|
||||
|
||||
type AlertOptions = { title: string
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { unlocalisedPath } from '@/lib/locales'
|
||||
import { i18n } from '@/lib/i18n'
|
||||
|
||||
export type ShortcutScope =
|
||||
| 'global'
|
||||
@@ -39,85 +40,109 @@ export type ShortcutDefinition = {
|
||||
}
|
||||
|
||||
export const SHORTCUT_SCOPE_LABELS: Record<ShortcutScope, string> = {
|
||||
global: '全画面',
|
||||
posts: '広場',
|
||||
postSearch: '広場検索',
|
||||
tags: 'タグ',
|
||||
settings: '設定',
|
||||
wiki: 'Wiki',
|
||||
materials: '素材',
|
||||
get global () { return i18n.t ('settings:shortcuts.scopes.global') },
|
||||
get posts () { return i18n.t ('settings:shortcuts.scopes.posts') },
|
||||
get postSearch () { return i18n.t ('settings:shortcuts.scopes.postSearch') },
|
||||
get tags () { return i18n.t ('settings:shortcuts.scopes.tags') },
|
||||
get settings () { return i18n.t ('settings:shortcuts.scopes.settings') },
|
||||
get wiki () { return i18n.t ('settings:shortcuts.scopes.wiki') },
|
||||
get materials () { return i18n.t ('settings:shortcuts.scopes.materials') },
|
||||
}
|
||||
|
||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||
{
|
||||
id: 'global.openShortcutHelp',
|
||||
label: 'ショートカット一覧を開く',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.global.openShortcutHelp')
|
||||
},
|
||||
scope: 'global',
|
||||
defaultBinding: { key: '?', shift: true },
|
||||
},
|
||||
{
|
||||
id: 'global.focusSearch',
|
||||
label: '検索欄へ移動',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.global.focusSearch')
|
||||
},
|
||||
scope: 'global',
|
||||
defaultBinding: { key: '/' },
|
||||
},
|
||||
{
|
||||
id: 'navigation.posts',
|
||||
label: '広場へ移動',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.navigation.posts')
|
||||
},
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'p' },
|
||||
},
|
||||
{
|
||||
id: 'navigation.tags',
|
||||
label: 'タグへ移動',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.navigation.tags')
|
||||
},
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 't' },
|
||||
},
|
||||
{
|
||||
id: 'navigation.materials',
|
||||
label: '素材へ移動',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.navigation.materials')
|
||||
},
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'm' },
|
||||
},
|
||||
{
|
||||
id: 'navigation.wiki',
|
||||
label: 'Wiki へ移動',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.navigation.wiki')
|
||||
},
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'w' },
|
||||
},
|
||||
{
|
||||
id: 'pagination.next',
|
||||
label: '次のページへ移動',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.pagination.next')
|
||||
},
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'ArrowRight' },
|
||||
},
|
||||
{
|
||||
id: 'pagination.previous',
|
||||
label: '前のページへ移動',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.pagination.previous')
|
||||
},
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'ArrowLeft' },
|
||||
},
|
||||
{
|
||||
id: 'settings.account',
|
||||
label: '設定: アカウント',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.settings.account')
|
||||
},
|
||||
scope: 'settings',
|
||||
defaultBinding: { key: '1' },
|
||||
},
|
||||
{
|
||||
id: 'settings.behavior',
|
||||
label: '設定: 動作',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.settings.behavior')
|
||||
},
|
||||
scope: 'settings',
|
||||
defaultBinding: { key: '2' },
|
||||
},
|
||||
{
|
||||
id: 'settings.theme',
|
||||
label: '設定: テーマ',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.settings.theme')
|
||||
},
|
||||
scope: 'settings',
|
||||
defaultBinding: { key: '3' },
|
||||
},
|
||||
{
|
||||
id: 'settings.keyboard',
|
||||
label: '設定: キーボード',
|
||||
get label () {
|
||||
return i18n.t ('settings:shortcuts.actions.settings.keyboard')
|
||||
},
|
||||
scope: 'settings',
|
||||
defaultBinding: { key: '4' },
|
||||
},
|
||||
@@ -251,7 +276,7 @@ const keyLabel = (key: string): string => {
|
||||
|
||||
export const formatKeyBinding = (binding: KeyBinding | null): string => {
|
||||
if (binding == null)
|
||||
return '未割当'
|
||||
return i18n.t ('settings:shortcuts.unassigned')
|
||||
|
||||
return [
|
||||
binding.ctrl ? 'Ctrl' : null,
|
||||
|
||||
@@ -131,7 +131,9 @@ export const wikiKeys = {
|
||||
title: string,
|
||||
p: { version?: string },
|
||||
locale: LocaleCode = 'ja',
|
||||
) => ['wiki', locale, title, p] as const }
|
||||
) => ['wiki', locale, title, p] as const,
|
||||
changes: (p: { id?: string }, locale: LocaleCode = 'ja') =>
|
||||
['wiki', locale, 'changes', 'index', p] as const }
|
||||
|
||||
export const helpKeys = {
|
||||
root: ['help'] as const,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useBlocker } from 'react-router-dom'
|
||||
|
||||
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
|
||||
@@ -34,6 +35,7 @@ const UnsavedChangesGuardContext =
|
||||
|
||||
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const dialogue = useDialogue ()
|
||||
const { label } = useUITranslation ('settings')
|
||||
const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
|
||||
const bypassNextNavigationRef = useRef<symbol | null> (null)
|
||||
const [revision, setRevision] = useState (0)
|
||||
@@ -99,8 +101,10 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
try
|
||||
{
|
||||
const confirmed = await dialogue.confirm ({
|
||||
title: '変更が破棄してページ移動しますか?',
|
||||
confirmText: '変更を破棄して移動',
|
||||
title: label ('unsaved.leavePage'),
|
||||
confirmText: label ('unsaved.discardAndMove'),
|
||||
cancelText: label ('actions.cancel'),
|
||||
closeLabel: label ('actions.close'),
|
||||
variant: 'danger' })
|
||||
if (!(confirmed))
|
||||
return false
|
||||
@@ -111,7 +115,7 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
{
|
||||
dialogueOpenRef.current = false
|
||||
}
|
||||
}, [dialogue, hasUnsavedChanges])
|
||||
}, [dialogue, hasUnsavedChanges, label])
|
||||
|
||||
useEffect (() => {
|
||||
if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
|
||||
|
||||
+36
-15
@@ -1,6 +1,8 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import { i18n } from '@/lib/i18n'
|
||||
|
||||
import type { ClassValue } from 'clsx'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
@@ -27,67 +29,86 @@ export const dateString = (
|
||||
second: unknown == null ? 'numeric' : undefined })
|
||||
|
||||
|
||||
export const localisedDateString = (value: string | Date, locale: LocaleCode): string => {
|
||||
export const localisedDateString = (
|
||||
value: string | Date,
|
||||
locale: LocaleCode,
|
||||
unknown: Parameters<typeof dateString>[1] = null,
|
||||
): string => {
|
||||
if (locale === 'ja')
|
||||
return dateString (value)
|
||||
return dateString (value, unknown)
|
||||
|
||||
const date = toDate (value)
|
||||
if (Number.isNaN (date.getTime ()))
|
||||
return dateString (value)
|
||||
return dateString (value, unknown)
|
||||
|
||||
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') }`
|
||||
const omitDay = unknown != null && ['month', 'day'].includes (unknown)
|
||||
const dateParts = [part ('year'),
|
||||
...(unknown === 'month' ? [] : [part ('month')]),
|
||||
...(omitDay ? [] : [part ('day')])]
|
||||
const dateText = dateParts.join ('-')
|
||||
if (unknown != null && ['month', 'day', 'hour'].includes (unknown))
|
||||
return dateText
|
||||
const timeParts = [part ('hour'),
|
||||
...(unknown === 'minute' ? [] : [part ('minute')]),
|
||||
...(unknown == null ? [part ('second')] : [])]
|
||||
return `${dateText}\u3000${timeParts.join (':')}`
|
||||
}
|
||||
|
||||
|
||||
export const originalCreatedAtString = (
|
||||
f: string | Date | null,
|
||||
b: string | Date | null): string => {
|
||||
b: string | Date | null,
|
||||
locale: LocaleCode = 'ja',
|
||||
): string => {
|
||||
const from = f ? toDate (f) : null
|
||||
const before = b ? toDate (b) : null
|
||||
const format = (value: Date, unknown: Parameters<typeof dateString>[1]) =>
|
||||
localisedDateString (value, locale, unknown)
|
||||
const unknownLabel = (key: string) => i18n.t (`history:${key}`, { lng: locale })
|
||||
const space = locale === 'ja' ? ' ' : '\u3000'
|
||||
|
||||
if (from && before)
|
||||
{
|
||||
const diff = before.getTime () - from.getTime ()
|
||||
|
||||
if (diff <= 60_000 /* 1 分 */)
|
||||
return dateString (from, 'second')
|
||||
return format (from, 'second')
|
||||
|
||||
if (from.getMinutes () === 0 && before.getMinutes () === 0)
|
||||
{
|
||||
if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000)
|
||||
return dateString (from, 'minute') + ' (分不詳)'
|
||||
return `${format (from, 'minute')}${space}(${unknownLabel ('unknownMinute')})`
|
||||
|
||||
if (from.getHours () === 0 && before.getHours () === 0)
|
||||
{
|
||||
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000)
|
||||
return dateString (from, 'hour') + ' (時刻不詳)'
|
||||
return `${format (from, 'hour')}${space}(${unknownLabel ('unknownTime')})`
|
||||
|
||||
if (from.getDate () === 1 && before.getDate () === 1)
|
||||
{
|
||||
if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */)
|
||||
return dateString (from, 'day') + ' (日不詳)'
|
||||
return `${format (from, 'day')}${space}(${unknownLabel ('unknownDay')})`
|
||||
|
||||
if (from.getMonth () === 0 && before.getMonth () === 0
|
||||
&& (31_449_600_000 /* 364 日 */ <= diff
|
||||
&& diff < 31_708_800_000 /* 367 日 */))
|
||||
return dateString (from, 'month') + ' (月日不詳)'
|
||||
return `${format (from, 'month')}${space}(${unknownLabel ('unknownMonthDay')})`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rtn = ([from ? `${ dateString (from, 'second') }` : '',
|
||||
const rtn = ([from ? format (from, 'second') : '',
|
||||
'〜',
|
||||
before ? `${ dateString (new Date (before.getTime () - 60_000), 'second') }` : '']
|
||||
before ? format (new Date (before.getTime () - 60_000), 'second') : '']
|
||||
.filter (Boolean)
|
||||
.join (' '))
|
||||
return rtn === '〜' ? '年月日不詳' : rtn
|
||||
.join (space))
|
||||
return rtn === '〜' ? unknownLabel ('unknownDate') : rtn
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@ import { isAxiosError } from 'axios'
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { WikiPage } from '@/types'
|
||||
import type { WikiPage, WikiPageChange } from '@/types'
|
||||
|
||||
|
||||
export const fetchWikiChanges = (
|
||||
params: { id?: string },
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<WikiPageChange[]> => apiGet ('/wiki/changes', { locale, params })
|
||||
|
||||
|
||||
export const fetchWikiPages = async (
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする