diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index 5d78f31..2617cde 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -80,6 +80,7 @@ class PostVersionsController < ApplicationController prev: row.attributes['prev_thumbnail_base'] }, video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] }, tags: build_version_tags(cur_tags, prev_tags), + parent_post_ids: row.parent_post_ids, original_created_from: { current: row.original_created_from&.iso8601, prev: row.attributes['prev_original_created_from']&.iso8601 }, @@ -121,10 +122,14 @@ class PostVersionsController < ApplicationController name = tag_names[snapshot['tag_id']] || snapshot.fetch('name') [tag_category_order(snapshot['category']), { identity: [:tag, snapshot['tag_id'], sections], - name: Post.tag_snapshot_literal(snapshot.merge('name' => name)) }] + name: Post.tag_snapshot_literal(snapshot.merge('name' => name)), + restore_name: tag_names[snapshot['tag_id']] && + Post.tag_snapshot_literal(snapshot.merge('name' => name)), + external: false }] elsif external_tag_names[snapshot['external_tag_id']] [6, { identity: [:external_tag, snapshot['external_tag_id']], - name: external_tag_names[snapshot['external_tag_id']] }] + name: external_tag_names[snapshot['external_tag_id']], + restore_name: nil, external: true }] end } .sort_by { |order, tag| [order, tag[:name].downcase] } @@ -136,8 +141,8 @@ class PostVersionsController < ApplicationController end def build_version_tags cur_tags, prev_tags - current = cur_tags.to_h { [_1[:identity], _1[:name]] } - previous = prev_tags.to_h { [_1[:identity], _1[:name]] } + current = cur_tags.index_by { _1[:identity] } + previous = prev_tags.index_by { _1[:identity] } (current.keys | previous.keys).map do |identity| type = @@ -149,7 +154,9 @@ class PostVersionsController < ApplicationController 'removed' end - { name: current[identity] || previous[identity], type: } + entry = current[identity] || previous[identity] + { name: entry[:name], restore_name: entry[:restore_name], + external: entry[:external], type: } end end end diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index a58aee9..ba5242c 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -35,10 +35,17 @@ class TagName < ApplicationRecord def self.generate_name locale, tag, base_tag_name fallback = "Tag_##{ tag.id }" - return fallback unless locale.language_code == 'ain' && base_tag_name&.language_code == 'ja' - - name = TagNameTranscription.ainu(base_tag_name.name) - candidate = new(tag:, name:, language_code: 'ain', script_code: 'Kana', + name = + case [locale.language_code, base_tag_name&.language_code] + when ['ain', 'ja'] + TagNameTranscription.ainu(base_tag_name.name) + when ['ja', 'ain'] + base_tag_name.name + else + return fallback + end + candidate = new(tag:, name:, language_code: locale.language_code, + script_code: locale.script_code, primary_flg: true, auto_generated: true) candidate.valid? ? name : fallback rescue TagNameTranscription::Unconvertible diff --git a/backend/app/services/tag_name_completion.rb b/backend/app/services/tag_name_completion.rb index 901017d..93e4327 100644 --- a/backend/app/services/tag_name_completion.rb +++ b/backend/app/services/tag_name_completion.rb @@ -16,7 +16,8 @@ class TagNameCompletion locales.to_a.uniq(&:language_code).each do |locale| next if primary_names.key?(locale.language_code) - name = TagName.generate_name(locale, tag, primary_names['ja']) + source_language = locale.language_code == 'ja' ? 'ain' : 'ja' + name = TagName.generate_name(locale, tag, primary_names[source_language]) primary_names[locale.language_code] = create_name!(locale, tag, name) end tag.association(:tag_names).reset @@ -29,10 +30,10 @@ class TagNameCompletion persist_name!(locale, tag, name) rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error fallback = "Tag_##{ tag.id }" - collision = + invalid_name = error.is_a?(ActiveRecord::RecordNotUnique) || - error.record.errors.of_kind?(:name, :taken) - raise if name == fallback || !(collision) + error.record.errors[:name].present? + raise if name == fallback || !(invalid_name) persist_name!(locale, tag, fallback) end diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index a7dcdfc..9ef49fc 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -142,23 +142,34 @@ const TopNav: FC = ({ user, locales }) => { const layoutRef = useRef (null) const barRef = useRef (null) const submenuContentRef = useRef (null) + const highlightTargetRef = useRef (-1) const measure = (idx: number) => { const nav = navRef.current const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx] - if (!(nav) || !(el)) - { - setHL ({ left: 0, width: 0, visible: true }) - return - } + if (nav == null || el == null || nav.offsetWidth === 0 + || layoutRef.current?.dataset.desktop !== 'true') + { + setHL (current => current.visible + ? { left: 0, width: 0, visible: false } : current) + return + } - const navRect = nav.getBoundingClientRect () - const elRect = el.getBoundingClientRect () - - setHL ({ left: elRect.left - navRect.left, - width: elRect.width, - visible: true }) + // Layout offsets exclude Framer Motion's temporary projection transforms. + let left = 0 + let node: HTMLElement | null = el + while (node != null && node !== nav) + { + left += node.offsetLeft + const parent: Element | null = node.offsetParent + node = parent instanceof HTMLElement ? parent : null + } + const width = node === nav ? el.offsetWidth : 0 + const visible = width > 0 + setHL (current => + (current.left === left && current.width === width && current.visible === visible) + ? current : { left, width, visible }) } const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({ @@ -200,11 +211,14 @@ const TopNav: FC = ({ user, locales }) => { const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname }) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) + const menuLabels = visibleMenu.map (item => item.name).join ('\u0000') const moreMenu = menu.filter (item => !(item.visible ?? true) || item.subMenu.filter (subItem => subItem.visible ?? true).length > 0) const activeIdx = visibleMenu.findIndex (item => pathname.startsWith (item.base || item.to)) + highlightTargetRef.current = moreVsbl ? -1 : activeIdx + const activeSubmenuId = `submenu-${ menu.indexOf (visibleMenu[activeIdx]) }` const topNavTransition = animationsOff ? { duration: 0 } @@ -300,6 +314,9 @@ const TopNav: FC = ({ user, locales }) => { typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule) observer?.observe (bar) observer?.observe (primary) + for (const item of itemsRef.current) + if (item != null) + observer?.observe (item) if (account != null) observer?.observe (account) update () @@ -314,7 +331,13 @@ const TopNav: FC = ({ user, locales }) => { removeEventListener ('resize', schedule) document.fonts?.removeEventListener ('loadingdone', schedule) } - }, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales]) + }, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels]) + + useLayoutEffect (() => { + measure (moreVsbl ? -1 : activeIdx) + if (!(desktop)) + setMoreVsbl (false) + }, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels]) useEffect (() => { const unsubscribe = WikiIdBus.subscribe (setWikiId) @@ -403,7 +426,8 @@ const TopNav: FC = ({ user, locales }) => { className="shrink-0 whitespace-nowrap" {...(animationsOff ? { } - : { layoutId: `menu-${ item.name }` })} + : { layoutId: `menu-${ menu.indexOf (item) }` })} + onLayoutAnimationComplete={() => measure (highlightTargetRef.current)} animate={{ opacity: moreVsbl ? 0 : 1 }} transition={{ opacity: opacityTransition, layout: topNavTransition }} @@ -472,7 +496,10 @@ const TopNav: FC = ({ user, locales }) => { }} transition={{ layout: topNavTransition }} onAnimationComplete={() => { - measure (moreVsbl ? -1 : activeIdx) + measure (highlightTargetRef.current) + }} + onLayoutAnimationComplete={() => { + measure (highlightTargetRef.current) }}>
{moreVsbl @@ -484,10 +511,11 @@ const TopNav: FC = ({ user, locales }) => { {...(animationsOff ? { } : ((item.visible ?? true) - ? { layoutId: `menu-${ item.name }` } + ? { layoutId: `menu-${ menu.indexOf (item) }` } : { initial: { x: 40, y: -40, opacity: 0 }, animate: { x: 0, y: 0, opacity: 1 }, exit: { x: 40, y: -40, opacity: 0 } }))} + onLayoutAnimationComplete={() => measure (highlightTargetRef.current)} className="z-10 min-w-0 px-3 py-2 font-bold">

@@ -507,9 +535,8 @@ const TopNav: FC = ({ user, locales }) => { transition={topNavTransition} {...(animationsOff ? { } - : ((visibleMenu[activeIdx]?.name - === item.name) - ? { layoutId: `submenu-${ item.name }-${ j }` } + : ((visibleMenu[activeIdx] === item) + ? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` } : { initial: { y: -40, opacity: 0 }, animate: { y: 0, opacity: 1 }, exit: { y: -40, opacity: 0 } }))}> @@ -522,9 +549,8 @@ const TopNav: FC = ({ user, locales }) => { transition={topNavTransition} {...(animationsOff ? { } - : ((visibleMenu[activeIdx]?.name - === item.name) - ? { layoutId: `submenu-${ item.name }-${ j }` } + : ((visibleMenu[activeIdx] === item) + ? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` } : { initial: { y: -40, opacity: 0 }, animate: { y: 0, opacity: 1 }, exit: { y: -40, opacity: 0 } }))}> @@ -591,7 +617,7 @@ const TopNav: FC = ({ user, locales }) => { key={`c-${ i }`} className="min-w-0 max-w-full" transition={{ layout: topNavTransition }} - layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}> + layoutId={`${ activeSubmenuId }-${ i }`}> {item.component} ) : ( @@ -599,7 +625,7 @@ const TopNav: FC = ({ user, locales }) => { key={`l-${ i }`} className="min-w-0 max-w-full" transition={{ layout: topNavTransition }} - layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}> + layoutId={`${ activeSubmenuId }-${ i }`}> = ({ id, messages }: Props) => { diff --git a/frontend/src/components/dialogues/DialogueProvider.tsx b/frontend/src/components/dialogues/DialogueProvider.tsx index 7170f76..bfe88e7 100644 --- a/frontend/src/components/dialogues/DialogueProvider.tsx +++ b/frontend/src/components/dialogues/DialogueProvider.tsx @@ -179,6 +179,7 @@ const DialogueProvider: FC = ({ children }) => { closeRequest (active.id, active.kind !== 'confirm' && null) }}> = ({ children }) => { if (!(open)) closeNestedConfirm (false) }}> - + {nestedConfirm.options.title} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index e3c154c..b272234 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -32,8 +32,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef + & { closeLabel?: React.ReactNode } >( - ({ className, children, ...props }, ref) => ( + ({ className, children, closeLabel = '閉ぢる', ...props }, ref) => ( - 閉ぢる + {closeLabel} )) diff --git a/frontend/src/components/users/BehaviourSettingsSection.tsx b/frontend/src/components/users/BehaviourSettingsSection.tsx index 4bf5e26..804e251 100644 --- a/frontend/src/components/users/BehaviourSettingsSection.tsx +++ b/frontend/src/components/users/BehaviourSettingsSection.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings' +import { useUITranslation } from '@/lib/useUITranslation' import { cn } from '@/lib/utils' import type { FC, ReactNode } from 'react' @@ -22,7 +23,7 @@ type Props = { type SegmentedOption = { value: T - label: string } + label: ReactNode } type SegmentedControlProps = { value: T @@ -30,27 +31,18 @@ type SegmentedControlProps = { onChange: (value: T) => void } type SettingBlockProps = { - title: string - description?: string + title: ReactNode + description?: ReactNode children: ReactNode } -const animationOptions: SegmentedOption[] = [ - { value: 'off', label: 'なし' }, - { value: 'reduced', label: '控えめ' }, - { value: 'normal', label: '通常' }] +const animationOptions: ClientAnimationMode[] = ['off', 'reduced', 'normal'] -const embedAutoLoadOptions: SegmentedOption[] = [ - { value: 'off', label: '読み込まない' }, - { value: 'manual', label: 'クリック時' }, - { value: 'auto', label: '自動' }] +const embedAutoLoadOptions: ClientEmbedAutoLoadMode[] = ['off', 'manual', 'auto'] -const linkPreloadOptions: SegmentedOption[] = [ - { value: 'off', label: 'しない' }, - { value: 'intent', label: 'する' }] +const linkPreloadOptions: ClientLinkPreloadMode[] = ['off', 'intent'] + +const tagRelationDisplayOptions: ClientTagRelationDisplayMode[] = ['flat', 'grouped'] -const tagRelationDisplayOptions: SegmentedOption[] = [ - { value: 'flat', label: 'まとめない' }, - { value: 'grouped', label: 'まとめる' }] const SegmentedControl = ( { value, options, onChange }: SegmentedControlProps, @@ -92,10 +84,9 @@ const SettingBlock: FC = (

) -const BehaviourSettingsSection: FC = ( - { sectionClassName, - onDirtyStateChange }, -) => { +const BehaviourSettingsSection: FC = props => { + const { sectionClassName, onDirtyStateChange } = props + const { label } = useUITranslation ('settings') const [savedSettings, setSavedSettings] = useState (() => getClientBehaviorSettings ()) const [draftSettings, setDraftSettings] = @@ -122,7 +113,7 @@ const BehaviourSettingsSection: FC = ( const next = setClientBehaviorSettings (draftSettings) setSavedSettings (next) setDraftSettings (next) - toast ({ title: '動作設定を保存しました' }) + toast ({ title: label ('behavior.saved') }) } const handleDiscard = () => { @@ -141,7 +132,7 @@ const BehaviourSettingsSection: FC = (
-

動作

+

{label ('tabs.behavior')}

@@ -150,45 +141,49 @@ const BehaviourSettingsSection: FC = ( variant="destructive" disabled={hasUnsavedChanges === false} onClick={handleDiscard}> - 変更を破棄 + {label ('actions.discard')}
- + ({ value, + label: label (`behavior.animationOptions.${value}`) }))} onChange={value => updateDraft ('animation', value)}/> + title={label ('behavior.preload')} + description={label ('behavior.preloadDescription')}> ({ value, + label: label (`behavior.preloadOptions.${value}`) }))} onChange={value => updateDraft ('linkPreload', value)}/> - + ({ value, + label: label (`behavior.relationOptions.${value}`) }))} onChange={value => updateDraft ('tagRelationDisplay', value)}/> - + ({ value, + label: label (`behavior.embedOptions.${value}`) }))} onChange={value => updateDraft ('embedAutoLoad', value)}/>
diff --git a/frontend/src/components/users/InheritDialogue.tsx b/frontend/src/components/users/InheritDialogue.tsx index 7ec996e..3876b8e 100644 --- a/frontend/src/components/users/InheritDialogue.tsx +++ b/frontend/src/components/users/InheritDialogue.tsx @@ -1,8 +1,6 @@ -import type { FC } from 'react' - import { useState } from 'react' -import { useDialogue } from '@/components/dialogues/DialogueProvider' +import { useDialogue } from '@/lib/dialogues/useDialogue' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, @@ -12,6 +10,9 @@ import { Dialog, import { Input } from '@/components/ui/input' import { toast } from '@/components/ui/use-toast' import { apiPost } from '@/lib/api' +import { useUITranslation } from '@/lib/useUITranslation' + +import type { FC } from 'react' import type { User } from '@/types' @@ -22,14 +23,17 @@ type Props = { visible: boolean const InheritDialogue: FC = ({ visible, onVisibleChange, setUser }) => { const dialogue = useDialogue () + const { message, label } = useUITranslation ('settings') const [inputCode, setInputCode] = useState ('') const handleTransfer = async () => { if (!(await dialogue.confirm ({ - title: '引継ぎを行ってもよろしいですか?', - description: '現在のアカウントからはログアウトされます.', - confirmText: '引継ぐ', + title: label ('transfer.confirm'), + description: label ('transfer.logoutWarning'), + confirmText: label ('transfer.submit'), + cancelText: label ('actions.cancel'), + closeLabel: label ('actions.close'), variant: 'danger' }))) return @@ -41,29 +45,31 @@ const InheritDialogue: FC = ({ visible, onVisibleChange, setUser }) => { { localStorage.setItem ('user_code', inputCode) setUser (data.user) - toast ({ title: '引継ぎ成功!' }) + toast ({ title: label ('transfer.success') }) onVisibleChange (false) } else - toast ({ title: '認証失敗', description: 'そのコードは使へません.' }) + toast ({ title: label ('transfer.authFailed'), description: label ('transfer.invalidCode') }) } catch { - toast ({ title: '通信エラー', description: 'またあとで試してね.' }) + toast ({ title: label ('transfer.networkError'), description: label ('transfer.retryLater') }) } } return ( - + - ほかのブラウザから引継ぐ + {label ('account.inherit')}
- setInputCode (ev.target.value)}/> - +
diff --git a/frontend/src/components/users/KeyboardSettingsSection.tsx b/frontend/src/components/users/KeyboardSettingsSection.tsx index dc56cf6..ea86f7b 100644 --- a/frontend/src/components/users/KeyboardSettingsSection.tsx +++ b/frontend/src/components/users/KeyboardSettingsSection.tsx @@ -8,14 +8,15 @@ import { isSpaceBinding, keyBindingFromKeyboardEvent, SHORTCUT_DEFINITIONS, - SHORTCUT_SCOPE_LABELS, } from '@/lib/keyboardShortcuts' import { getEffectiveKeyBindings } from '@/lib/settings' import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts' +import { useUITranslation } from '@/lib/useUITranslation' + +import type { FC } from 'react' import type { ShortcutActionId } from '@/lib/keyboardShortcuts' import type { ClientKeyboardSettings } from '@/lib/settings' -import type { FC } from 'react' type Props = { sectionClassName: string @@ -26,10 +27,9 @@ type Props = { } -const KeyboardSettingsSection: FC = ( - { sectionClassName, - onDirtyStateChange }, -) => { +const KeyboardSettingsSection: FC = props => { + const { sectionClassName, onDirtyStateChange } = props + const { label } = useUITranslation ('settings') const { keyboardSettings, saveKeyboardSettings, @@ -92,7 +92,7 @@ const KeyboardSettingsSection: FC = ( if (isSpaceBinding (binding)) { - toast ({ title: 'Space は今は割り当てられません.' }) + toast ({ title: label ('shortcuts.spaceUnavailable') }) return } @@ -108,7 +108,7 @@ const KeyboardSettingsSection: FC = ( document.addEventListener ('keydown', onKeyDown, true) return () => document.removeEventListener ('keydown', onKeyDown, true) - }, [capturingActionId]) + }, [capturingActionId, label]) const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({ ...definition, @@ -126,7 +126,7 @@ const KeyboardSettingsSection: FC = ( const handleSave = () => { saveKeyboardSettings (draftKeyboardSettings) - toast ({ title: 'キーボード設定を保存しました' }) + toast ({ title: label ('shortcuts.saved') }) } const handleDiscard = () => { @@ -146,7 +146,7 @@ const KeyboardSettingsSection: FC = (
-

キーボード

+

{label ('tabs.keyboard')}

@@ -159,20 +159,20 @@ const KeyboardSettingsSection: FC = ( bindings: { }, })) }}> - すべて既定に戻す + {label ('actions.resetAll')}
@@ -187,33 +187,35 @@ const KeyboardSettingsSection: FC = ( enabled: event.target.checked, })) }}/> - キーボード・ショートカットを有効にする + {label ('shortcuts.enable')} {capturingActionId != null && (

- キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます. + {label ('shortcuts.captureHelp')}

)}
- - - + + + - + {rows.map (row => ( - - + + ))} diff --git a/frontend/src/components/users/ShortcutHelpDialog.tsx b/frontend/src/components/users/ShortcutHelpDialog.tsx index f00b072..36353b2 100644 --- a/frontend/src/components/users/ShortcutHelpDialog.tsx +++ b/frontend/src/components/users/ShortcutHelpDialog.tsx @@ -5,19 +5,13 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' -import { - formatKeyBinding, - SHORTCUT_DEFINITIONS, - SHORTCUT_SCOPE_LABELS, -} from '@/lib/keyboardShortcuts' +import { formatKeyBinding, SHORTCUT_DEFINITIONS } from '@/lib/keyboardShortcuts' +import { useUITranslation } from '@/lib/useUITranslation' -import type { - KeyBinding, - ShortcutActionId, - ShortcutScope, -} from '@/lib/keyboardShortcuts' import type { FC } from 'react' +import type { KeyBinding, ShortcutActionId, ShortcutScope } from '@/lib/keyboardShortcuts' + type Props = { open: boolean onOpenChange: (open: boolean) => void @@ -28,16 +22,10 @@ type Props = { } -const ShortcutHelpDialog: FC = ( - { - open, - onOpenChange, - activeScope, - effectiveBindings, - conflictActionIds, - availableActionIds, - }, -) => { +const ShortcutHelpDialog: FC = props => { + const { open, onOpenChange, activeScope, effectiveBindings, + conflictActionIds, availableActionIds } = props + const { label } = useUITranslation ('settings') const rows = SHORTCUT_DEFINITIONS .filter (definition => definition.scope === 'global' || definition.scope === activeScope) @@ -45,11 +33,12 @@ const ShortcutHelpDialog: FC = ( return ( - + - キーボード・ショートカット + {label ('shortcuts.title')} - この画面で利用できるショートカットを表示します。 + {label ('shortcuts.description')} @@ -57,24 +46,28 @@ const ShortcutHelpDialog: FC = (
操作適用範囲現在のキー{label ('shortcuts.operation')}{label ('shortcuts.scope')}{label ('shortcuts.currentKey')} 競合状態{label ('shortcuts.conflictState')}
{row.label}{SHORTCUT_SCOPE_LABELS[row.scope]}{label (`shortcuts.actions.${row.id}`)}{label (`shortcuts.scopes.${row.scope}`)} - {formatKeyBinding (row.currentBinding)} + {row.currentBinding == null + ? label ('shortcuts.unassigned') + : {formatKeyBinding (row.currentBinding)}} @@ -244,13 +247,15 @@ const KeyboardSettingsSection: FC = ( } }) }}> - 既定に戻す + {label ('actions.reset')} {row.hasConflict - ? 競合あり - : 'なし'} + ? + {label ('shortcuts.conflicting')} + + : label ('shortcuts.none')}
- - - - + + + + {rows.map (row => ( - - + + ))} diff --git a/frontend/src/components/users/UserCodeDialogue.tsx b/frontend/src/components/users/UserCodeDialogue.tsx index 7c3d2da..c953684 100644 --- a/frontend/src/components/users/UserCodeDialogue.tsx +++ b/frontend/src/components/users/UserCodeDialogue.tsx @@ -1,6 +1,4 @@ -import type { FC } from 'react' - -import { useDialogue } from '@/components/dialogues/DialogueProvider' +import { useDialogue } from '@/lib/dialogues/useDialogue' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, @@ -10,6 +8,9 @@ import { Dialog, DialogTitle } from '@/components/ui/dialog' import { toast } from '@/components/ui/use-toast' import { apiPost } from '@/lib/api' +import { useUITranslation } from '@/lib/useUITranslation' + +import type { FC } from 'react' import type { User } from '@/types' @@ -21,18 +22,21 @@ type Props = { visible: boolean const UserCodeDialogue: FC = ({ visible, onVisibleChange, user, setUser }) => { const dialogue = useDialogue () + const { label } = useUITranslation ('settings') const handleChange = async () => { if (!(user)) return if (!(await dialogue.confirm ({ - title: '引継ぎコードを再発行しますか?', + title: label ('userCode.confirm'), description: (
-

再発行するとほかのブラウザからはログアウトされます.

+

{label ('userCode.logoutWarning')}

), - confirmText: '再発行', + confirmText: label ('userCode.confirmRenew'), + cancelText: label ('actions.cancel'), + closeLabel: label ('actions.close'), variant: 'danger' }))) return @@ -42,22 +46,22 @@ const UserCodeDialogue: FC = ({ visible, onVisibleChange, user, setUser } { localStorage.setItem ('user_code', data.code) setUser (user => ({ ...user, inheritanceCode: data.code } as User)) - toast ({ title: '再発行しました.' }) + toast ({ title: label ('userCode.renewed') }) } } return ( - + - 引継ぎコード + {label ('userCode.title')}
-

あなたの引継ぎコードはこちらです:

+

{label ('userCode.description')}

{user?.inheritanceCode}

- このコードはほかの人には教えないでください! + {label ('userCode.secretWarning')}

@@ -65,7 +69,7 @@ const UserCodeDialogue: FC = ({ visible, onVisibleChange, user, setUser }
diff --git a/frontend/src/lib/dialogues/useDialogue.ts b/frontend/src/lib/dialogues/useDialogue.ts index 579c824..d4ab77d 100644 --- a/frontend/src/lib/dialogues/useDialogue.ts +++ b/frontend/src/lib/dialogues/useDialogue.ts @@ -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 diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts index 121ca6d..a436781 100644 --- a/frontend/src/lib/keyboardShortcuts.ts +++ b/frontend/src/lib/keyboardShortcuts.ts @@ -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 = { - 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, diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 8047513..6da28ee 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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, diff --git a/frontend/src/lib/useUnsavedChangesGuard.tsx b/frontend/src/lib/useUnsavedChangesGuard.tsx index 868e538..306e4af 100644 --- a/frontend/src/lib/useUnsavedChangesGuard.tsx +++ b/frontend/src/lib/useUnsavedChangesGuard.tsx @@ -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 = ({ children }) => { const dialogue = useDialogue () + const { label } = useUITranslation ('settings') const sourcesRef = useRef (new Map ()) const bypassNextNavigationRef = useRef (null) const [revision, setRevision] = useState (0) @@ -99,8 +101,10 @@ export const UnsavedChangesGuardProvider: FC = ({ 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 = ({ children }) { dialogueOpenRef.current = false } - }, [dialogue, hasUnsavedChanges]) + }, [dialogue, hasUnsavedChanges, label]) useEffect (() => { if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 23c5999..1082eab 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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[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[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 } diff --git a/frontend/src/lib/wiki.ts b/frontend/src/lib/wiki.ts index 1ea7392..015e8f8 100644 --- a/frontend/src/lib/wiki.ts +++ b/frontend/src/lib/wiki.ts @@ -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 => apiGet ('/wiki/changes', { locale, params }) export const fetchWikiPages = async ( diff --git a/frontend/src/locales/ain-Kana/history.json b/frontend/src/locales/ain-Kana/history.json new file mode 100644 index 0000000..9c83f71 --- /dev/null +++ b/frontend/src/locales/ain-Kana/history.json @@ -0,0 +1,37 @@ +{ + "_untranslated": [ + "tagTitle", "materialTitle", "postPrefix", "tagPrefix", "post", "version", + "materialId", "filename", "actor", "filter", "materialError", "confirmTitle", + "confirmPost", "confirmTag", "revert", "restored", "restoreFailed", + "originalPostedAt", "unknownMinute", "unknownTime", "unknownDay", + "unknownMonthDay", "unknownDate" + ], + "postTitle": "$t(tags:history)", + "tagTitle": "タグ定義変更履歴", + "materialTitle": "素材履歴", + "postPrefix": ": 投稿 ", + "tagPrefix": ": タグ ", + "post": "投稿", + "version": "版", + "name": "$t(tags:name)", + "tagName": "$t(navigation:tags) $t(tags:name)", + "materialId": "素材 ID", + "filename": "ファイル名", + "actor": "操作者", + "filter": "絞り込む", + "materialError": "素材履歴の取得に失敗しました.", + "anonymous": "$t(shared:labels.anonymous)(#{{id}})", + "bot": "$t(shared:labels.bot)", + "confirmTitle": "差戻の確認", + "confirmPost": "『{{name}}』を版 {{version}} に差戻します.\nよろしいですか?", + "confirmTag": "タグ『{{name}}』を版 {{version}} に差戻します.\nよろしいですか?", + "revert": "差戻", + "restored": "差戻しました.", + "restoreFailed": "差戻に失敗……", + "originalPostedAt": "オリジナルの投稿日時", + "unknownMinute": "分不詳", + "unknownTime": "時刻不詳", + "unknownDay": "日不詳", + "unknownMonthDay": "月日不詳", + "unknownDate": "年月日不詳" +} diff --git a/frontend/src/locales/ain-Kana/settings.json b/frontend/src/locales/ain-Kana/settings.json new file mode 100644 index 0000000..7de29b8 --- /dev/null +++ b/frontend/src/locales/ain-Kana/settings.json @@ -0,0 +1,142 @@ +{ + "_untranslated": [ + "tabList", "tabs", "actions.save", "actions.discard", "actions.reset", + "actions.resetAll", "actions.change", "actions.close", "account", "transfer", + "userCode", "behavior", "theme.modes", "theme.slot", "theme.saveToSlot", "theme.displayMode", + "theme.whenLight", "theme.whenDark", "theme.basic", "theme.tokens", + "theme.previews.deerjikist", "theme.previews.meme", "theme.previews.meta", + "theme.saved", "theme.failed", "unsaved", "loadFailed", + "shortcuts.title", "shortcuts.description", "shortcuts.enable", "shortcuts.saved", + "shortcuts.spaceUnavailable", "shortcuts.captureHelp", "shortcuts.capturing", + "shortcuts.unassigned", "shortcuts.operation", "shortcuts.scope", "shortcuts.key", + "shortcuts.currentKey", "shortcuts.conflict", "shortcuts.conflictState", + "shortcuts.conflicting", "shortcuts.none", "shortcuts.scopes.global", + "shortcuts.scopes.postSearch", "shortcuts.actions" + ], + "title": "$t(navigation:settings)", + "tabList": "設定区分", + "tabs": { + "account": "アカウント", + "behavior": "動作", + "theme": "テーマ", + "keyboard": "キーボード" + }, + "actions": { + "save": "保存", + "discard": "変更を破棄", + "reset": "既定に戻す", + "resetAll": "すべて既定に戻す", + "change": "変更", + "cancel": "$t(shared:actions.cancel)", + "close": "閉ぢる" + }, + "account": { + "displayName": "表示名", + "unnamedWarning": "名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!!", + "updateName": "表示名を更新", + "transfer": "引継ぎ", + "showCode": "引継ぎコードを表示", + "inherit": "ほかのブラウザから引継ぐ", + "nameSaved": "表示名を更新しました.", + "nameFailed": "表示名を更新できませんでした." + }, + "transfer": { + "confirm": "引継ぎを行ってもよろしいですか?", + "logoutWarning": "現在のアカウントからはログアウトされます.", + "submit": "引継ぐ", + "success": "引継ぎ成功!", + "authFailed": "認証失敗", + "invalidCode": "そのコードは使へません.", + "networkError": "通信エラー", + "retryLater": "またあとで試してね.", + "placeholder": "引継ぎコードを入力" + }, + "userCode": { + "title": "引継ぎコード", + "description": "あなたの引継ぎコードはこちらです:", + "secretWarning": "このコードはほかの人には教えないでください!", + "renew": "引継ぎコード再発行", + "confirm": "引継ぎコードを再発行しますか?", + "logoutWarning": "再発行するとほかのブラウザからはログアウトされます.", + "confirmRenew": "再発行", + "renewed": "再発行しました." + }, + "behavior": { + "saved": "動作設定を保存しました", + "animation": "アニメーション", + "animationOptions": { "off": "なし", "reduced": "控えめ", "normal": "通常" }, + "preload": "リンク先の先読み", + "preloadDescription": "マウスを置いた時点でリンク先のデータを取得します。", + "preloadOptions": { "off": "しない", "intent": "する" }, + "tagRelations": "タグの親子関係表示", + "relationOptions": { "flat": "まとめない", "grouped": "まとめる" }, + "embeds": "埋め込み自動読込", + "embedOptions": { "off": "読み込まない", "manual": "クリック時", "auto": "自動" } + }, + "theme": { + "modes": { "system": "システム設定に従う", "light": "ライト", "dark": "ダーク" }, + "slot": "{{mode}} {{number}}", + "saveToSlot": "{{slot}} に保存", + "displayMode": "表示モード", + "whenLight": "ライト時", + "whenDark": "ダーク時", + "basic": "基本", + "tokens": { "background": "背景", "foreground": "文字", "link": "リンク" }, + "previews": { + "deerjikist": "にじらー", "meme": "ミーム", + "character": "$t(common:categories.character)", + "general": "$t(common:categories.general)", + "material": "$t(navigation:materials)", "meta": "メタ", "nico": "nico:sm9" + }, + "saved": "テーマ設定を保存しました.", + "failed": "テーマ設定を保存できませんでした." + }, + "unsaved": { + "title": "未保存の変更があります", + "leaveTab": "このタブを離れると、保存していない変更は失われます。", + "stayTab": "このタブに残る", + "discardAndMove": "変更を破棄して移動", + "switchTheme": "このまま切り替えると、保存していない変更は失われます。", + "keepEditing": "このまま編集する", + "discardAndSwitch": "変更を破棄して切り替える", + "leavePage": "変更が破棄してページ移動しますか?" + }, + "loadFailed": "設定の読込みに失敗しました.", + "shortcuts": { + "title": "キーボード・ショートカット", + "description": "この画面で利用できるショートカットを表示します。", + "enable": "キーボード・ショートカットを有効にする", + "saved": "キーボード設定を保存しました", + "spaceUnavailable": "Space は今は割り当てられません.", + "captureHelp": "キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます.", + "capturing": "入力待ち…", + "unassigned": "未割当", + "operation": "操作", + "scope": "適用範囲", + "key": "キー", + "currentKey": "現在のキー", + "conflict": "競合", + "conflictState": "競合状態", + "conflicting": "競合あり", + "none": "なし", + "scopes": { + "global": "全画面", "posts": "$t(navigation:posts)", "postSearch": "広場検索", + "tags": "$t(navigation:tags)", "settings": "$t(navigation:settings)", + "wiki": "$t(navigation:wiki)", "materials": "$t(navigation:materials)" + }, + "actions": { + "global": { + "openShortcutHelp": "ショートカット一覧を開く", "focusSearch": "検索欄へ移動" + }, + "navigation": { + "posts": "広場へ移動", "tags": "タグへ移動", + "materials": "素材へ移動", "wiki": "Wiki へ移動" + }, + "pagination": { "next": "次のページへ移動", "previous": "前のページへ移動" }, + "settings": { + "account": "設定: アカウント", "behavior": "設定: 動作", + "theme": "設定: テーマ", "keyboard": "設定: キーボード" + } + } + } +} diff --git a/frontend/src/locales/ain-Kana/shared.json b/frontend/src/locales/ain-Kana/shared.json index 1528b9a..4999d83 100644 --- a/frontend/src/locales/ain-Kana/shared.json +++ b/frontend/src/locales/ain-Kana/shared.json @@ -26,7 +26,7 @@ "resultsCount": "{{count}} ペ", "menu": "メニュー", "anonymous": "レサㇰ ユㇰ ニチカ ウタㇻ", - "bot": "bot カㇻ", + "bot": "Bot カㇻ", "deprecatedSuffix": "(エイワンケ ソモ エアㇱカイ)", "development": "カㇻ コㇿ アン シㇼキ" }, "actions": { diff --git a/frontend/src/locales/ja/history.json b/frontend/src/locales/ja/history.json new file mode 100644 index 0000000..78bb061 --- /dev/null +++ b/frontend/src/locales/ja/history.json @@ -0,0 +1,30 @@ +{ + "postTitle": "耕作履歴", + "tagTitle": "タグ定義変更履歴", + "materialTitle": "素材履歴", + "postPrefix": ": 投稿 ", + "tagPrefix": ": タグ ", + "post": "投稿", + "version": "版", + "name": "名称", + "tagName": "タグ名", + "materialId": "素材 ID", + "filename": "ファイル名", + "actor": "操作者", + "filter": "絞り込む", + "materialError": "素材履歴の取得に失敗しました.", + "anonymous": "名もなきニジラー(#{{id}})", + "bot": "bot 操作", + "confirmTitle": "差戻の確認", + "confirmPost": "『{{name}}』を版 {{version}} に差戻します.\nよろしいですか?", + "confirmTag": "タグ『{{name}}』を版 {{version}} に差戻します.\nよろしいですか?", + "revert": "差戻", + "restored": "差戻しました.", + "restoreFailed": "差戻に失敗……", + "originalPostedAt": "オリジナルの投稿日時", + "unknownMinute": "分不詳", + "unknownTime": "時刻不詳", + "unknownDay": "日不詳", + "unknownMonthDay": "月日不詳", + "unknownDate": "年月日不詳" +} diff --git a/frontend/src/locales/ja/settings.json b/frontend/src/locales/ja/settings.json new file mode 100644 index 0000000..1ede9e8 --- /dev/null +++ b/frontend/src/locales/ja/settings.json @@ -0,0 +1,125 @@ +{ + "title": "設定", + "tabList": "設定区分", + "tabs": { + "account": "アカウント", + "behavior": "動作", + "theme": "テーマ", + "keyboard": "キーボード" + }, + "actions": { + "save": "保存", + "discard": "変更を破棄", + "reset": "既定に戻す", + "resetAll": "すべて既定に戻す", + "change": "変更", + "cancel": "取消", + "close": "閉ぢる" + }, + "account": { + "displayName": "表示名", + "unnamedWarning": "名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!!", + "updateName": "表示名を更新", + "transfer": "引継ぎ", + "showCode": "引継ぎコードを表示", + "inherit": "ほかのブラウザから引継ぐ", + "nameSaved": "表示名を更新しました.", + "nameFailed": "表示名を更新できませんでした." + }, + "transfer": { + "confirm": "引継ぎを行ってもよろしいですか?", + "logoutWarning": "現在のアカウントからはログアウトされます.", + "submit": "引継ぐ", + "success": "引継ぎ成功!", + "authFailed": "認証失敗", + "invalidCode": "そのコードは使へません.", + "networkError": "通信エラー", + "retryLater": "またあとで試してね.", + "placeholder": "引継ぎコードを入力" + }, + "userCode": { + "title": "引継ぎコード", + "description": "あなたの引継ぎコードはこちらです:", + "secretWarning": "このコードはほかの人には教えないでください!", + "renew": "引継ぎコード再発行", + "confirm": "引継ぎコードを再発行しますか?", + "logoutWarning": "再発行するとほかのブラウザからはログアウトされます.", + "confirmRenew": "再発行", + "renewed": "再発行しました." + }, + "behavior": { + "saved": "動作設定を保存しました", + "animation": "アニメーション", + "animationOptions": { "off": "なし", "reduced": "控えめ", "normal": "通常" }, + "preload": "リンク先の先読み", + "preloadDescription": "マウスを置いた時点でリンク先のデータを取得します。", + "preloadOptions": { "off": "しない", "intent": "する" }, + "tagRelations": "タグの親子関係表示", + "relationOptions": { "flat": "まとめない", "grouped": "まとめる" }, + "embeds": "埋め込み自動読込", + "embedOptions": { "off": "読み込まない", "manual": "クリック時", "auto": "自動" } + }, + "theme": { + "modes": { "system": "システム設定に従う", "light": "ライト", "dark": "ダーク" }, + "slot": "{{mode}} {{number}}", + "saveToSlot": "{{slot}} に保存", + "displayMode": "表示モード", + "whenLight": "ライト時", + "whenDark": "ダーク時", + "basic": "基本", + "tokens": { "background": "背景", "foreground": "文字", "link": "リンク" }, + "previews": { + "deerjikist": "にじらー", "meme": "ミーム", "character": "キャラクター", + "general": "一般", "material": "素材", "meta": "メタ", "nico": "nico:sm9" + }, + "saved": "テーマ設定を保存しました.", + "failed": "テーマ設定を保存できませんでした." + }, + "unsaved": { + "title": "未保存の変更があります", + "leaveTab": "このタブを離れると、保存していない変更は失われます。", + "stayTab": "このタブに残る", + "discardAndMove": "変更を破棄して移動", + "switchTheme": "このまま切り替えると、保存していない変更は失われます。", + "keepEditing": "このまま編集する", + "discardAndSwitch": "変更を破棄して切り替える", + "leavePage": "変更が破棄してページ移動しますか?" + }, + "loadFailed": "設定の読込みに失敗しました.", + "shortcuts": { + "title": "キーボード・ショートカット", + "description": "この画面で利用できるショートカットを表示します。", + "enable": "キーボード・ショートカットを有効にする", + "saved": "キーボード設定を保存しました", + "spaceUnavailable": "Space は今は割り当てられません.", + "captureHelp": "キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます.", + "capturing": "入力待ち…", + "unassigned": "未割当", + "operation": "操作", + "scope": "適用範囲", + "key": "キー", + "currentKey": "現在のキー", + "conflict": "競合", + "conflictState": "競合状態", + "conflicting": "競合あり", + "none": "なし", + "scopes": { + "global": "全画面", "posts": "広場", "postSearch": "広場検索", + "tags": "タグ", "settings": "設定", "wiki": "Wiki", "materials": "素材" + }, + "actions": { + "global": { + "openShortcutHelp": "ショートカット一覧を開く", "focusSearch": "検索欄へ移動" + }, + "navigation": { + "posts": "広場へ移動", "tags": "タグへ移動", + "materials": "素材へ移動", "wiki": "Wiki へ移動" + }, + "pagination": { "next": "次のページへ移動", "previous": "前のページへ移動" }, + "settings": { + "account": "設定: アカウント", "behavior": "設定: 動作", + "theme": "設定: テーマ", "keyboard": "設定: キーボード" + } + } + } +} diff --git a/frontend/src/pages/help/HelpChangesPage.tsx b/frontend/src/pages/help/HelpChangesPage.tsx index df6092c..acfcaa9 100644 --- a/frontend/src/pages/help/HelpChangesPage.tsx +++ b/frontend/src/pages/help/HelpChangesPage.tsx @@ -14,14 +14,15 @@ import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' import { helpKeys } from '@/lib/queryKeys' import { LIST_LIMIT_OPTIONS } from '@/lib/settings' -import { dateString } from '@/lib/utils' +import { localisedDateString } from '@/lib/utils' import type { FC } from 'react' const HelpChangesPage: FC = () => { const { message, label } = useUITranslation ('help') - const language = localeLanguage (useLocale ()) + const locale = useLocale () + const language = localeLanguage (locale) const [searchParams, setSearchParams] = useSearchParams () const identifier = searchParams.get ('identifier') ?? '' const pageValue = searchParams.get ('page') ?? '1' @@ -72,7 +73,7 @@ const HelpChangesPage: FC = () => { {isLoading ?

{label ('shared:messages.loading')}

: (error - ?

{error.message}

+ ?

{label ('common:fetchError')}

: (
@@ -109,7 +110,7 @@ const HelpChangesPage: FC = () => {
) })} diff --git a/frontend/src/pages/materials/MaterialHistoryPage.tsx b/frontend/src/pages/materials/MaterialHistoryPage.tsx index b8e2308..6834128 100644 --- a/frontend/src/pages/materials/MaterialHistoryPage.tsx +++ b/frontend/src/pages/materials/MaterialHistoryPage.tsx @@ -14,12 +14,14 @@ import { clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchMaterialChanges } from '@/lib/materials' import { materialsKeys } from '@/lib/queryKeys' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' -import { dateString, inputClass } from '@/lib/utils' +import { useUITranslation } from '@/lib/useUITranslation' +import { localisedDateString, inputClass } from '@/lib/utils' import type { FC, FormEvent } from 'react' const MaterialHistoryPage: FC = () => { + const { message, label } = useUITranslation ('history') const locale = useLocale () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' @@ -83,11 +85,13 @@ const MaterialHistoryPage: FC = () => { return ( - {`素材履歴 | ${ SITE_TITLE }`} + + {message ('materialTitle').text} | {SITE_TITLE} +
- 素材履歴 + {label ('materialTitle')}
{ text-stone-900 dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100">
- + {({ invalid }) => ( setMaterialIdInput (e.target.value)} className={inputClass (invalid)}/>)} - + {({ invalid }) => ( setTagInput (e.target.value)} className={inputClass (invalid)}/>)} @@ -119,16 +127,16 @@ const MaterialHistoryPage: FC = () => { className="mt-4 rounded-full border border-stone-300 bg-white px-4 py-2 text-sm text-stone-900 hover:bg-stone-100 dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800"> - 絞り込む + {label ('filter')} {isLoading - ? 'Loading...' - : isError + ? label ('common:loading') + : (isError ? ( -

- 素材履歴の取得に失敗しました. +

+ {label ('materialError')}

) : ( <> @@ -147,14 +155,14 @@ const MaterialHistoryPage: FC = () => {
- - - - - + + + + + - + @@ -163,7 +171,9 @@ const MaterialHistoryPage: FC = () => { - + ))} @@ -187,7 +197,7 @@ const MaterialHistoryPage: FC = () => { - )} + ))} ) } diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index 3a9a5e5..26b1688 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -9,7 +9,7 @@ import TagLink from '@/components/TagLink' import PrefetchLink from '@/components/PrefetchLink' import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' -import { useDialogue } from '@/components/dialogues/DialogueProvider' +import { useDialogue } from '@/lib/dialogues/useDialogue' import MainArea from '@/components/layout/MainArea' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' @@ -19,7 +19,8 @@ import { fetchPostChanges, updatePost } from '@/lib/posts' import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { fetchExternalTag, fetchTag } from '@/lib/tags' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' -import { cn, dateString, originalCreatedAtString } from '@/lib/utils' +import { useUITranslation } from '@/lib/useUITranslation' +import { cn, localisedDateString, originalCreatedAtString } from '@/lib/utils' import type { FC, MouseEvent } from 'react' @@ -40,6 +41,7 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => ( const PostHistoryPage: FC = () => { + const { message, label } = useUITranslation ('history') const locale = useLocale () const dialogue = useDialogue () const behaviourSettings = useClientBehaviourSettings () @@ -72,7 +74,7 @@ const PostHistoryPage: FC = () => { queryKey: tagsKeys.externalShow (externalTagQueryId, locale), queryFn: () => fetchExternalTag (externalTagQueryId, locale) }) - const { data, isLoading: loading } = useQuery ({ + const { data, isLoading: loading, isError } = useQuery ({ queryKey: postsKeys.changes ({ ...(id && { post: id }), ...(tagId && { tag: tagId }), ...(externalTagId && { externalTag: externalTagId }), @@ -85,7 +87,8 @@ const PostHistoryPage: FC = () => { const changes = data?.versions ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 const displayedTag = externalTag ?? tag - const pageTitleLabel = `耕作履歴${ id ? `: 投稿 #${ id }` : '' }${ + const pageTitleLabel = `${message ('postTitle').text}${ + id ? `${message ('postPrefix').text}#${id}` : '' }${ displayedTag ? `(${ displayedTag.name })` : '' }` const qc = useQueryClient () @@ -94,27 +97,24 @@ const PostHistoryPage: FC = () => { e.preventDefault () if (!(await dialogue.confirm ({ - title: '差戻の確認', - description: `『${ change.title.current || change.url.current }』を版 ${ - change.versionNo } に差戻します.\nよろしいですか?`, - confirmText: '差戻' }))) + title: label ('confirmTitle'), + description: label ('confirmPost', { + name: change.title.current || change.url.current, version: change.versionNo }), + confirmText: label ('revert'), + cancelText: label ('settings:actions.cancel'), + closeLabel: label ('settings:actions.close') }))) return try { const id = change.postId const title = change.title.current - const tags = - change.tags - .filter (t => t.type !== 'removed') - .map (t => t.name) - .filter (t => t.slice (0, 5) !== 'nico:') - .join (' ') - const parentPostIds = - (change.parentPosts ?? []) - .filter (p => p.type !== 'removed') - .map (p => p.id) - .join (' ') + const currentTags = change.tags.filter (tag => tag.type !== 'removed' && !(tag.external)) + if (currentTags.some (tag => tag.restoreName == null) + || typeof change.parentPostIds !== 'string') + throw new Error () + const tags = currentTags.map (tag => tag.restoreName).join (' ') + const parentPostIds = change.parentPostIds const duration = change.videoMs.current == null ? null @@ -128,11 +128,11 @@ const PostHistoryPage: FC = () => { qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root }) - toast ({ description: '差戻しました.' }) + toast ({ description: label ('restored') }) } catch { - toast ({ description: '差戻に失敗……' }) + toast ({ description: label ('restoreFailed') }) } } @@ -146,12 +146,17 @@ const PostHistoryPage: FC = () => { return ( - {`耕作履歴 | ${ SITE_TITLE }`} + + {message ('postTitle').text} | {SITE_TITLE} + - - 耕作履歴 - {id && <>: 投稿 {#{id}}} + + {label ('postTitle')} + {id && ( + <>{label ('postPrefix')} + #{id} + )} {displayedTag && ( <>( { withCount={false}/>))} - {loading ? 'Loading...' : ( + {isError &&

{label ('common:fetchError')}

} + {loading ? label ('common:loading') : ( <>
操作適用範囲キー競合{label ('shortcuts.operation')}{label ('shortcuts.scope')}{label ('shortcuts.key')}{label ('shortcuts.conflict')}
{row.label}{SHORTCUT_SCOPE_LABELS[row.scope]}{label (`shortcuts.actions.${row.id}`)}{label (`shortcuts.scopes.${row.scope}`)} - {formatKeyBinding (effectiveBindings[row.id])} + {effectiveBindings[row.id] == null + ? label ('shortcuts.unassigned') + : {formatKeyBinding (effectiveBindings[row.id])}} {conflictActionIds.has (row.id) - ? 競合あり - : 'なし'} + ? + {label ('shortcuts.conflicting')} + + : label ('shortcuts.none')}
- {updatedAt != null && dateString (updatedAt, 'hour')} + {updatedAt != null && localisedDateString (updatedAt, locale, 'hour')}
日時素材 ID版タグ名ファイル名{label ('wiki:dateTime')}{label ('materialId')}{label ('version')}{label ('tagName')}{label ('filename')} URL export_path操作者{label ('actor')}
{dateString (version.createdAt)} + {localisedDateString (version.createdAt, locale)} + #{version.materialId} @@ -179,7 +189,7 @@ const MaterialHistoryPage: FC = () => { {version.createdByUser ? version.createdByUser.name || `#${ version.createdByUser.id }` - : 'bot 操作'} + : label ('bot')}
@@ -186,15 +192,15 @@ const PostHistoryPage: FC = () => { - - - + + + - + {/* TODO: 親投稿の履歴 */} {/* */} - - + + @@ -286,32 +292,34 @@ const PostHistoryPage: FC = () => { {pp.title} ))))} */} - ) diff --git a/frontend/src/pages/tags/TagHistoryPage.tsx b/frontend/src/pages/tags/TagHistoryPage.tsx index 28061cc..3a224bf 100644 --- a/frontend/src/pages/tags/TagHistoryPage.tsx +++ b/frontend/src/pages/tags/TagHistoryPage.tsx @@ -10,42 +10,40 @@ import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' -import { CATEGORY_NAMES } from '@/consts' import { apiPut } from '@/lib/api' +import { useDialogue } from '@/lib/dialogues/useDialogue' import { clientScrollBehaviour } from '@/lib/clientAnimation' import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { fetchTagChanges } from '@/lib/tags' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' -import { cn, dateString } from '@/lib/utils' +import { useUITranslation } from '@/lib/useUITranslation' +import { cn, localisedDateString } from '@/lib/utils' -import type { FC } from 'react' +import type { FC, ReactNode } from 'react' -const renderDiff = (diff: { current: string | null; prev: string | null }) => ( +const renderDiff = ( + diff: { current: string | null; prev: string | null }, + format: (value: string) => ReactNode = value => value, +) => ( <> {diff.prev !== diff.current ? ( <> - {diff.prev && <>{diff.prev}
} + {diff.prev && <>{format (diff.prev)}
}
- {diff.current} + {diff.current != null && format (diff.current)} ) - : diff.current} + : diff.current != null && format (diff.current)} ) -const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : '' - - -const renderStateDiff = (diff: { current: string | null; prev: string | null }) => - renderDiff ({ current: tagStateLabel (diff.current), - prev: tagStateLabel (diff.prev) }) - - const TagHistoryPage: FC = () => { + const { message, label } = useUITranslation ('history') + const dialogue = useDialogue () const locale = useLocale () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' @@ -55,7 +53,7 @@ const TagHistoryPage: FC = () => { const page = Number (query.get ('page') ?? 1) const limit = Number (query.get ('limit') ?? 20) - const { data, isLoading: loading } = useQuery ({ + const { data, isLoading: loading, isError } = useQuery ({ queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }, locale), queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }, locale) }) const changes = data?.versions ?? [] @@ -71,15 +69,21 @@ const TagHistoryPage: FC = () => { return ( - {`タグ定義変更履歴 | ${ SITE_TITLE }`} + + {message ('tagTitle').text} | {SITE_TITLE} + - タグ定義変更履歴 - {id && <>: タグ {#{id}}} + {label ('tagTitle')} + {id && ( + <>{label ('tagPrefix')} + #{id} + )} - {loading ? 'Loading...' : ( + {isError &&

{label ('common:fetchError')}

} + {loading ? label ('common:loading') : ( <>
投稿版タイトル{label ('post')}{label ('version')}{label ('shared:labels.title')} URLタグ{label ('navigation:tags')}親投稿オリジナルの投稿日時更新日時{label ('originalPostedAt')}{label ('tags:updatedAt')}
+ {change.versionNo === 1 - ? originalCreatedAtString (change.originalCreatedFrom.current, - change.originalCreatedBefore.current) + ? originalCreatedAtString ( + change.originalCreatedFrom.current, + change.originalCreatedBefore.current, locale) : renderDiff ({ current: originalCreatedAtString ( - change.originalCreatedFrom.current, - change.originalCreatedBefore.current), + change.originalCreatedFrom.current, + change.originalCreatedBefore.current, locale), prev: originalCreatedAtString ( - change.originalCreatedFrom.prev, - change.originalCreatedBefore.prev) })} + change.originalCreatedFrom.prev, + change.originalCreatedBefore.prev, locale) })} {change.createdByUser ? ( {change.createdByUser.name - || `名もなきニジラー(#${ change.createdByUser.id })`} + || label ('anonymous', { id: change.createdByUser.id })} ) - : 'bot 操作'} + : label ('bot')}
- {dateString (change.createdAt)} + {localisedDateString (change.createdAt, locale)}
await handleRevert (e, change)}> - 復元 + {label ('shared:actions.restore')}
@@ -104,13 +108,13 @@ const TagHistoryPage: FC = () => { - - - - - - - + + + + + + + @@ -122,13 +126,14 @@ const TagHistoryPage: FC = () => { ))} diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 572a76c..d5ecc99 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -2,23 +2,24 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useBeforeUnload, useLocation } from 'react-router-dom' -import { useLocaleNavigate } from '@/lib/useLocale' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' import Label from '@/components/common/Label' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import TagLink from '@/components/TagLink' -import { useDialogue } from '@/components/dialogues/DialogueProvider' +import { useDialogue } from '@/lib/dialogues/useDialogue' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection' import InheritDialogue from '@/components/users/InheritDialogue' import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection' import UserCodeDialogue from '@/components/users/UserCodeDialogue' -import { CATEGORY_NAMES, CATEGORIES } from '@/consts' +import { CATEGORIES } from '@/consts' import { SITE_TITLE } from '@/config' import { apiPut } from '@/lib/api' +import { i18n } from '@/lib/i18n' import { applyClientAppearance, applyThemeAppearanceState, @@ -41,8 +42,9 @@ import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard' import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { cn, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' +import { useUITranslation } from '@/lib/useUITranslation' -import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react' +import type { CSSProperties, Dispatch, FC, KeyboardEvent, ReactNode, SetStateAction } from 'react' import type { Category, Tag, User } from '@/types' import type { @@ -85,7 +87,6 @@ type ThemeModeItem = { type ThemeSelectionItem = { id: UserThemeSlotSelection - label: string baseTheme: BuiltinThemeId slotNo: UserThemeSlotNo } @@ -129,7 +130,7 @@ type ThemeDraftState = { type ThemeSegmentedOption = { value: T - label: string } + label: ReactNode } type ThemeSegmentedControlProps = { value: T @@ -137,15 +138,15 @@ type ThemeSegmentedControlProps = { onChange: (value: T) => void } const tabs: TabSpec[] = [ - { id: 'account', label: 'アカウント' }, - { id: 'behavior', label: '動作' }, - { id: 'theme', label: 'テーマ' }, - { id: 'keyboard', label: 'キーボード' } ] + { id: 'account', label: 'tabs.account' }, + { id: 'behavior', label: 'tabs.behavior' }, + { id: 'theme', label: 'tabs.theme' }, + { id: 'keyboard', label: 'tabs.keyboard' }] const themeModeSelections: ThemeModeItem[] = [ - { id: 'system', label: 'システム設定に従う' }, - { id: 'light', label: 'ライト' }, - { id: 'dark', label: 'ダーク' } ] + { id: 'system', label: 'theme.modes.system' }, + { id: 'light', label: 'theme.modes.light' }, + { id: 'dark', label: 'theme.modes.dark' }] const themeSlotNoSelections: ThemeSegmentedOption[] = [ { value: 1, label: '1' }, @@ -156,20 +157,19 @@ const sectionClassName = 'space-y-4 rounded-xl p-4' const themeSelections: ThemeSelectionItem[] = [ - { id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 }, - { id: 'user:light:2', label: 'ライト 2', baseTheme: 'light', slotNo: 2 }, - { id: 'user:light:3', label: 'ライト 3', baseTheme: 'light', slotNo: 3 }, - { id: 'user:dark:1', label: 'ダーク 1', baseTheme: 'dark', slotNo: 1 }, - { id: 'user:dark:2', label: 'ダーク 2', baseTheme: 'dark', slotNo: 2 }, - { id: 'user:dark:3', label: 'ダーク 3', baseTheme: 'dark', slotNo: 3 }, -] + { id: 'user:light:1', baseTheme: 'light', slotNo: 1 }, + { id: 'user:light:2', baseTheme: 'light', slotNo: 2 }, + { id: 'user:light:3', baseTheme: 'light', slotNo: 3 }, + { id: 'user:dark:1', baseTheme: 'dark', slotNo: 1 }, + { id: 'user:dark:2', baseTheme: 'dark', slotNo: 2 }, + { id: 'user:dark:3', baseTheme: 'dark', slotNo: 3 }] const themeTokenGroups: ThemeTokenGroupSpec[] = [ - { title: '基本', + { title: 'theme.basic', fields: [ - { key: 'background', label: '背景' }, - { key: 'foreground', label: '文字' }, - { key: 'link', label: 'リンク' }] }] + { key: 'background', label: 'theme.tokens.background' }, + { key: 'foreground', label: 'theme.tokens.foreground' }, + { key: 'link', label: 'theme.tokens.link' }] }] const ThemeSegmentedControl = ( @@ -220,12 +220,12 @@ const previewTag = ( children: [] }) const previewTags: Record = { - deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }), - meme: previewTag (2, 'ミーム', 'meme', 34, true), - character: previewTag (3, 'キャラクター', 'character', 56, false), - general: previewTag (4, '一般', 'general', 78, true), - material: previewTag (5, '素材', 'material', 90, false), - meta: previewTag (6, 'メタ', 'meta', 21, true), + deerjikist: previewTag (1, '', 'deerjikist', 12, true, { hasDeerjikists: true }), + meme: previewTag (2, '', 'meme', 34, true), + character: previewTag (3, '', 'character', 56, false), + general: previewTag (4, '', 'general', 78, true), + material: previewTag (5, '', 'material', 90, false), + meta: previewTag (6, '', 'meta', 21, true), nico: previewTag (7, 'nico:sm9', 'nico', 43, false) } const parseTab = (search: string): SettingsTab => { @@ -244,8 +244,16 @@ const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties => [`--tag-colour-${ category }`, tagColours[category]], [`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties -const themeLabelBySelection = (selection: UserThemeSlotSelection): string => - themeSelections.find (item => item.id === selection)?.label ?? 'ライト 1' +const themeLabelBySelection = ( + selection: UserThemeSlotSelection, + locale: string, +): string => { + const item = themeSelections.find (entry => entry.id === selection) ?? themeSelections[0] + return i18n.t ('settings:theme.slot', { + lng: locale, + mode: i18n.t (`settings:theme.modes.${item.baseTheme}`, { lng: locale }), + number: item.slotNo }) +} const ensureCompleteThemeSlots = ( themeSlots: UserThemeSlotMap, @@ -417,91 +425,79 @@ const hexToThemeToken = ( isTopNavThemeTokenKey (key) ? value : hexToHslToken (value) -const AccountSection: FC = ( - { name, - onInheritOpen, - onNameChange, - onUserCodeOpen, - onUserSubmit, - user, - userBaseErrors, - userFieldErrors }, -) => ( -
-

アカウント

- +const AccountSection: FC = props => { + const { name, onInheritOpen, onNameChange, onUserCodeOpen, + onUserSubmit, user, userBaseErrors, userFieldErrors } = props + const { message, label } = useUITranslation ('settings') + return ( +
+

{label ('tabs.account')}

+ - - {({ describedBy, invalid }) => ( - <> - onNameChange (ev.target.value)}/> - {!(user.name) && ( -

- 名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!! -

)} - )} -
+ + {({ describedBy, invalid }) => ( + <> + onNameChange (ev.target.value)}/> + {!(user.name) && ( +

+ {label ('account.unnamedWarning')} +

)} + )} +
-
- -
- -
-
- -
-
-
) -const ThemeSection: FC = ( - { draftThemeMode, - draftActiveLightThemeSlotNo, - draftActiveDarkThemeSlotNo, - currentDisplayedThemeSlot, - selectedTheme, - hasUnsavedChanges, - settingsBaseErrors, - settingsFieldErrors, - onSelectThemeMode, - onSelectLightThemeSlotNo, - onSelectDarkThemeSlotNo, - onThemeTokenChange, - onTagColourChange, - onResetThemeSlot, - onThemeSubmit, - onThemeDiscard }, -) => { +
+ +
+ + +
+
+
) +} + + +const ThemeSection: FC = props => { + const { draftThemeMode, draftActiveLightThemeSlotNo, draftActiveDarkThemeSlotNo, + currentDisplayedThemeSlot, selectedTheme, hasUnsavedChanges, + settingsBaseErrors, settingsFieldErrors, onSelectThemeMode, + onSelectLightThemeSlotNo, onSelectDarkThemeSlotNo, onThemeTokenChange, + onTagColourChange, onResetThemeSlot, onThemeSubmit, onThemeDiscard } = props + const { message, label } = useUITranslation ('settings') + const locale = useLocale () return (
-

テーマ

+

{label ('tabs.theme')}

{hasUnsavedChanges && (

- 未保存の変更があります + {label ('unsaved.title')}

)}
@@ -510,7 +506,7 @@ const ThemeSection: FC = ( type="button" variant="outline" onClick={onResetThemeSlot}> - 既定に戻す + {label ('actions.reset')}
@@ -535,42 +532,48 @@ const ThemeSection: FC = (
- +
({ value: selection.id, - label: selection.label }))} + label: label (selection.label) }))} onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
- +
- +
@@ -583,17 +586,19 @@ const ThemeSection: FC = (
-

{group.title}

+

{label (group.title)}

{group.fields.map (field => (
-

{field.label}

+

{label (field.label)}

onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))} @@ -608,7 +613,7 @@ const ThemeSection: FC = (
-

タグ

+

{label ('navigation:tags')}

@@ -619,10 +624,14 @@ const ThemeSection: FC = ( className="flex items-center justify-between gap-4 border-b border-border/60 py-2 last:border-b-0">
-

{CATEGORY_NAMES[category]}

+

+ {label (`common:categories.${category}`)} +

@@ -632,6 +641,8 @@ const ThemeSection: FC = (
onTagColourChange (category, ev.target.value)} className="h-9 w-14 cursor-pointer rounded border border-border @@ -647,6 +658,8 @@ const ThemeSection: FC = ( const SettingPage: FC = ({ user, setUser }) => { + const { message, label } = useUITranslation ('settings') + const locale = useLocale () const location = useLocation () const navigate = useLocaleNavigate () const dialogue = useDialogue () @@ -708,12 +721,12 @@ const SettingPage: FC = ({ user, setUser }) => { ?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo) return { id: currentSelection.baseTheme, - name: themeLabelBySelection (currentSelection.id), + name: themeLabelBySelection (currentSelection.id, locale), builtin: false, baseTheme: currentSelection.baseTheme, tokens: themeSlot.tokens } }, - [displayedThemeSelection.selection, draftThemeSlots]) + [displayedThemeSelection.selection, draftThemeSlots, locale]) const hasUnsavedThemeChanges = useMemo ( () => serialiseThemeTokens (draftThemeSlots, displayedThemeSelection.selection) @@ -753,12 +766,13 @@ const SettingPage: FC = ({ user, setUser }) => { } const confirmDiscardChanges = async ( - description: string, - cancelText: string, - confirmText: string, + description: ReactNode, + cancelText: ReactNode, + confirmText: ReactNode, ): Promise => dialogue.confirm ({ - title: '未保存の変更があります', + title: label ('unsaved.title'), + closeLabel: label ('actions.close'), description, confirmText, cancelText, @@ -866,9 +880,9 @@ const SettingPage: FC = ({ user, setUser }) => { } const confirmed = await confirmDiscardChanges ( - 'このタブを離れると、保存していない変更は失われます。', - 'このタブに残る', - '変更を破棄して移動') + label ('unsaved.leaveTab'), + label ('unsaved.stayTab'), + label ('unsaved.discardAndMove')) if (!(confirmed)) return @@ -878,9 +892,9 @@ const SettingPage: FC = ({ user, setUser }) => { const requestThemeStateChange = async ( buildNextState: (baseState: ThemeDraftState) => ThemeDraftState, - description = 'このまま切り替えると、保存していない変更は失われます。', - cancelText = 'このまま編集する', - confirmText = '変更を破棄して切り替える', + description = label ('unsaved.switchTheme'), + cancelText = label ('unsaved.keepEditing'), + confirmText = label ('unsaved.discardAndSwitch'), ): Promise => { const baseState = currentThemeDraftState () let themeSlots = baseState.themeSlots @@ -1033,12 +1047,12 @@ const SettingPage: FC = ({ user, setUser }) => { { headers: { 'Content-Type': 'multipart/form-data' } }) setUser (currentUser => ({ ...currentUser, ...data })) setName (data.name ?? '') - toast ({ title: '表示名を更新しました.' }) + toast ({ title: label ('account.nameSaved') }) } catch (submitError) { applyUserValidationError (submitError) - toast ({ title: '表示名を更新できませんでした.' }) + toast ({ title: label ('account.nameFailed') }) } } @@ -1069,12 +1083,12 @@ const SettingPage: FC = ({ user, setUser }) => { setDraftThemeSlots (completeThemeSlots) setCachedUserThemeSlots (completeThemeSlots) applyClientAppearance (completeThemeSlots) - toast ({ title: 'テーマ設定を保存しました.' }) + toast ({ title: label ('theme.saved') }) } catch (submitError) { applySettingsValidationError (submitError) - toast ({ title: 'テーマ設定を保存できませんでした.' }) + toast ({ title: label ('theme.failed') }) } } @@ -1133,7 +1147,7 @@ const SettingPage: FC = ({ user, setUser }) => { if (cancelled) return - setSettingsError ('設定の読込みに失敗しました.') + setSettingsError ('loadFailed') } finally { @@ -1223,8 +1237,8 @@ const SettingPage: FC = ({ user, setUser }) => { draftActiveDarkThemeSlotNo, currentDisplayedThemeSlot: displayedThemeSelection.selection, selectedTheme, - displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection), - systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト', + displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection, locale), + systemThemeLabel: message (`theme.modes.${displayedThemeSelection.baseTheme}`).text, hasUnsavedChanges: hasUnsavedThemeChanges, settingsBaseErrors, settingsFieldErrors, @@ -1262,15 +1276,16 @@ const SettingPage: FC = ({ user, setUser }) => { - 設定 | {SITE_TITLE} + {message ('title').text} | {SITE_TITLE}
- 設定 - + {label ('title')} +
{tabs.map (tab => ( @@ -1291,11 +1306,11 @@ const SettingPage: FC = ({ user, setUser }) => { onClick={() => { requestActiveTab (tab.id) }}> - {tab.label} + {label (tab.label)} {settingsTabErrors[tab.id] ? ' !' : ''} ))}
- {loading ? 'Loading...' : ( + {loading ? label ('common:loading') : (
= ({ user, setUser }) => {
{tabs.map (tab => ( @@ -1346,15 +1362,15 @@ const SettingPage: FC = ({ user, setUser }) => { requestActiveTab (tab.id) }} onKeyDown={handleTabKeyDown}> - {tab.label} + {label (tab.label)} {settingsTabErrors[tab.id] ? ' ⚠' : ''} ))}
- 設定 - - {loading ? 'Loading...' : ( + {label ('title')} + + {loading ? label ('common:loading') : (
{ const { message, label } = useUITranslation ('wiki') const locale = useLocale () - const [changes, setChanges] = useState ([]) const location = useLocation () const query = new URLSearchParams (location.search) const id = query.get ('id') - useEffect (() => { - void (async () => { - setChanges (await apiGet ('/wiki/changes', { locale, params: id ? { id } : { } })) - }) () - }, [id, location.search, locale]) + const params = id ? { id } : { } + const { data: changes = [], isLoading, isError } = useQuery ({ + queryKey: wikiKeys.changes (params, locale), + queryFn: () => fetchWikiChanges (params, locale) }) return ( @@ -38,6 +35,8 @@ const WikiHistoryPage: FC = () => { {label ('historyHeading')} + {isLoading &&

{label ('common:loading')}

} + {isError &&

{label ('common:fetchError')}

}
版名称カテゴリ状態別名上位タグ更新日時{label ('version')}{label ('name')}{label ('common:categoryLabel')}{label ('common:stateLabel')}{label ('tags:aliases')}{label ('tags:parents')}{label ('tags:updatedAt')}
{change.tagId}.{change.versionNo} {renderDiff (change.name)} - {renderDiff ({ - current: CATEGORY_NAMES[change.category.current], - prev: (change.category.prev - && CATEGORY_NAMES[change.category.prev]) })} + {renderDiff (change.category, + category => label (`common:categories.${category}`))} - {renderStateDiff (change.deprecatedAt)} + {renderDiff ({ + current: change.deprecatedAt.current ? 'deprecated' : '', + prev: change.deprecatedAt.prev ? 'deprecated' : '' }, + state => state ? label (`common:states.${state}`) : '')} {change.aliases.map ((tag, i) => ( @@ -179,11 +184,11 @@ const TagHistoryPage: FC = () => { ? ( {change.createdByUser.name - || `名もなきニジラー(#${ change.createdByUser.id })`} + || label ('anonymous', { id: change.createdByUser.id })} ) - : 'bot 操作'} + : label ('bot')}
- {dateString (change.createdAt)} + {localisedDateString (change.createdAt, locale)}
{ onClick={async e => { e.preventDefault () - if (!(confirm ( - `タグ『${ change.name.current }』を版 ${ - change.versionNo } に差戻します.\nよろしいですか?`))) + if (!(await dialogue.confirm ({ + title: label ('confirmTitle'), + description: label ('confirmTag', { + name: change.name.current ?? '', + version: change.versionNo }), + confirmText: label ('revert'), + cancelText: label ('settings:actions.cancel'), + closeLabel: label ('settings:actions.close') }))) return try { + if (!(change.name.current) + || change.parentTags.some (tag => + tag.type !== 'removed' && !(tag.tag.name))) + throw new Error () await apiPut ( `/tags/${ change.tagId }`, { name: change.name.current, @@ -216,14 +230,14 @@ const TagHistoryPage: FC = () => { qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root }) - toast ({ description: '差戻しました.' }) + toast ({ description: label ('restored') }) } catch { - toast ({ description: '差戻に失敗……' }) + toast ({ description: label ('restoreFailed') }) } }}> - 復元 + {label ('shared:actions.restore')}
@@ -66,14 +65,15 @@ const WikiHistoryPage: FC = () => { {change.wikiPage.deprecatedAt != null && label ('shared:labels.deprecatedSuffix')} ))} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a7b4660..17c9b71 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -325,7 +325,10 @@ export type PostVersion = { thumbnailBase: { current: string | null; prev: string | null } videoMs: { current: number | null; prev: number | null } tags: { name: string + restoreName: string | null + external: boolean type: 'context' | 'added' | 'removed' }[] + parentPostIds: string parentPosts: { id: number title: string type: 'context' | 'added' | 'removed' }[] @@ -366,7 +369,7 @@ export type TagVersion = { tagId: number versionNo: number eventType: 'create' | 'update' | 'discard' | 'restore' - name: { current: string; prev: string | null } + name: { current: string | null; prev: string | null } category: { current: Category; prev: Category | null } deprecatedAt: { current: string | null; prev: string | null } aliases: { name: string; type: 'context' | 'added' | 'removed' }[]
- {change.pred == null ? label ('navigation:create') : label ('shared:actions.update')} + {change.pred == null + ? label ('navigation:create') : label ('shared:actions.update')} {change.user.name}
- {dateString (change.timestamp)} + {localisedDateString (change.timestamp, locale)}