このコミットが含まれているのは:
2026-10-18 01:39:03 +09:00
コミット eda81cfa89
30個のファイルの変更、965行の追加、473行の削除
+12 -5
ファイルの表示
@@ -80,6 +80,7 @@ class PostVersionsController < ApplicationController
prev: row.attributes['prev_thumbnail_base'] }, prev: row.attributes['prev_thumbnail_base'] },
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] }, video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
tags: build_version_tags(cur_tags, prev_tags), tags: build_version_tags(cur_tags, prev_tags),
parent_post_ids: row.parent_post_ids,
original_created_from: { original_created_from: {
current: row.original_created_from&.iso8601, current: row.original_created_from&.iso8601,
prev: row.attributes['prev_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') name = tag_names[snapshot['tag_id']] || snapshot.fetch('name')
[tag_category_order(snapshot['category']), [tag_category_order(snapshot['category']),
{ identity: [:tag, snapshot['tag_id'], sections], { 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']] elsif external_tag_names[snapshot['external_tag_id']]
[6, { identity: [:external_tag, 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 end
} }
.sort_by { |order, tag| [order, tag[:name].downcase] } .sort_by { |order, tag| [order, tag[:name].downcase] }
@@ -136,8 +141,8 @@ class PostVersionsController < ApplicationController
end end
def build_version_tags cur_tags, prev_tags def build_version_tags cur_tags, prev_tags
current = cur_tags.to_h { [_1[:identity], _1[:name]] } current = cur_tags.index_by { _1[:identity] }
previous = prev_tags.to_h { [_1[:identity], _1[:name]] } previous = prev_tags.index_by { _1[:identity] }
(current.keys | previous.keys).map do |identity| (current.keys | previous.keys).map do |identity|
type = type =
@@ -149,7 +154,9 @@ class PostVersionsController < ApplicationController
'removed' 'removed'
end 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 end
end end
+11 -4
ファイルの表示
@@ -35,10 +35,17 @@ class TagName < ApplicationRecord
def self.generate_name locale, tag, base_tag_name def self.generate_name locale, tag, base_tag_name
fallback = "Tag_##{ tag.id }" fallback = "Tag_##{ tag.id }"
return fallback unless locale.language_code == 'ain' && base_tag_name&.language_code == 'ja' name =
case [locale.language_code, base_tag_name&.language_code]
name = TagNameTranscription.ainu(base_tag_name.name) when ['ain', 'ja']
candidate = new(tag:, name:, language_code: 'ain', script_code: 'Kana', 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) primary_flg: true, auto_generated: true)
candidate.valid? ? name : fallback candidate.valid? ? name : fallback
rescue TagNameTranscription::Unconvertible rescue TagNameTranscription::Unconvertible
+5 -4
ファイルの表示
@@ -16,7 +16,8 @@ class TagNameCompletion
locales.to_a.uniq(&:language_code).each do |locale| locales.to_a.uniq(&:language_code).each do |locale|
next if primary_names.key?(locale.language_code) 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) primary_names[locale.language_code] = create_name!(locale, tag, name)
end end
tag.association(:tag_names).reset tag.association(:tag_names).reset
@@ -29,10 +30,10 @@ class TagNameCompletion
persist_name!(locale, tag, name) persist_name!(locale, tag, name)
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
fallback = "Tag_##{ tag.id }" fallback = "Tag_##{ tag.id }"
collision = invalid_name =
error.is_a?(ActiveRecord::RecordNotUnique) || error.is_a?(ActiveRecord::RecordNotUnique) ||
error.record.errors.of_kind?(:name, :taken) error.record.errors[:name].present?
raise if name == fallback || !(collision) raise if name == fallback || !(invalid_name)
persist_name!(locale, tag, fallback) persist_name!(locale, tag, fallback)
end end
+46 -20
ファイルの表示
@@ -142,23 +142,34 @@ const TopNav: FC<Props> = ({ user, locales }) => {
const layoutRef = useRef<HTMLDivElement | null> (null) const layoutRef = useRef<HTMLDivElement | null> (null)
const barRef = useRef<HTMLElement | null> (null) const barRef = useRef<HTMLElement | null> (null)
const submenuContentRef = useRef<HTMLDivElement | null> (null) const submenuContentRef = useRef<HTMLDivElement | null> (null)
const highlightTargetRef = useRef (-1)
const measure = (idx: number) => { const measure = (idx: number) => {
const nav = navRef.current const nav = navRef.current
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx] const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
if (!(nav) || !(el)) if (nav == null || el == null || nav.offsetWidth === 0
|| layoutRef.current?.dataset.desktop !== 'true')
{ {
setHL ({ left: 0, width: 0, visible: true }) setHL (current => current.visible
? { left: 0, width: 0, visible: false } : current)
return return
} }
const navRect = nav.getBoundingClientRect () // Layout offsets exclude Framer Motion's temporary projection transforms.
const elRect = el.getBoundingClientRect () let left = 0
let node: HTMLElement | null = el
setHL ({ left: elRect.left - navRect.left, while (node != null && node !== nav)
width: elRect.width, {
visible: true }) 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 }> ({ const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
@@ -200,11 +211,14 @@ const TopNav: FC<Props> = ({ user, locales }) => {
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname }) const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) 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 => const moreMenu = menu.filter (item =>
!(item.visible ?? true) !(item.visible ?? true)
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0) || item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
const activeIdx = const activeIdx =
visibleMenu.findIndex (item => pathname.startsWith (item.base || item.to)) visibleMenu.findIndex (item => pathname.startsWith (item.base || item.to))
highlightTargetRef.current = moreVsbl ? -1 : activeIdx
const activeSubmenuId = `submenu-${ menu.indexOf (visibleMenu[activeIdx]) }`
const topNavTransition = const topNavTransition =
animationsOff animationsOff
? { duration: 0 } ? { duration: 0 }
@@ -300,6 +314,9 @@ const TopNav: FC<Props> = ({ user, locales }) => {
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule) typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
observer?.observe (bar) observer?.observe (bar)
observer?.observe (primary) observer?.observe (primary)
for (const item of itemsRef.current)
if (item != null)
observer?.observe (item)
if (account != null) if (account != null)
observer?.observe (account) observer?.observe (account)
update () update ()
@@ -314,7 +331,13 @@ const TopNav: FC<Props> = ({ user, locales }) => {
removeEventListener ('resize', schedule) removeEventListener ('resize', schedule)
document.fonts?.removeEventListener ('loadingdone', 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 (() => { useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId) const unsubscribe = WikiIdBus.subscribe (setWikiId)
@@ -403,7 +426,8 @@ const TopNav: FC<Props> = ({ user, locales }) => {
className="shrink-0 whitespace-nowrap" className="shrink-0 whitespace-nowrap"
{...(animationsOff {...(animationsOff
? { } ? { }
: { layoutId: `menu-${ item.name }` })} : { layoutId: `menu-${ menu.indexOf (item) }` })}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
animate={{ opacity: moreVsbl ? 0 : 1 }} animate={{ opacity: moreVsbl ? 0 : 1 }}
transition={{ opacity: opacityTransition, transition={{ opacity: opacityTransition,
layout: topNavTransition }} layout: topNavTransition }}
@@ -472,7 +496,10 @@ const TopNav: FC<Props> = ({ user, locales }) => {
}} }}
transition={{ layout: topNavTransition }} transition={{ layout: topNavTransition }}
onAnimationComplete={() => { onAnimationComplete={() => {
measure (moreVsbl ? -1 : activeIdx) measure (highlightTargetRef.current)
}}
onLayoutAnimationComplete={() => {
measure (highlightTargetRef.current)
}}> }}>
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}> <div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
{moreVsbl {moreVsbl
@@ -484,10 +511,11 @@ const TopNav: FC<Props> = ({ user, locales }) => {
{...(animationsOff {...(animationsOff
? { } ? { }
: ((item.visible ?? true) : ((item.visible ?? true)
? { layoutId: `menu-${ item.name }` } ? { layoutId: `menu-${ menu.indexOf (item) }` }
: { initial: { x: 40, y: -40, opacity: 0 }, : { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 }, animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))} exit: { x: 40, y: -40, opacity: 0 } }))}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
className="z-10 min-w-0 px-3 py-2 font-bold"> className="z-10 min-w-0 px-3 py-2 font-bold">
<h2> <h2>
<LocalisedText language={item.nameLanguage ?? 'ja'}> <LocalisedText language={item.nameLanguage ?? 'ja'}>
@@ -507,9 +535,8 @@ const TopNav: FC<Props> = ({ user, locales }) => {
transition={topNavTransition} transition={topNavTransition}
{...(animationsOff {...(animationsOff
? { } ? { }
: ((visibleMenu[activeIdx]?.name : ((visibleMenu[activeIdx] === item)
=== item.name) ? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 }, : { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 }, animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}> exit: { y: -40, opacity: 0 } }))}>
@@ -522,9 +549,8 @@ const TopNav: FC<Props> = ({ user, locales }) => {
transition={topNavTransition} transition={topNavTransition}
{...(animationsOff {...(animationsOff
? { } ? { }
: ((visibleMenu[activeIdx]?.name : ((visibleMenu[activeIdx] === item)
=== item.name) ? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 }, : { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 }, animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}> exit: { y: -40, opacity: 0 } }))}>
@@ -591,7 +617,7 @@ const TopNav: FC<Props> = ({ user, locales }) => {
key={`c-${ i }`} key={`c-${ i }`}
className="min-w-0 max-w-full" className="min-w-0 max-w-full"
transition={{ layout: topNavTransition }} transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}> layoutId={`${ activeSubmenuId }-${ i }`}>
{item.component} {item.component}
</motion.div>) </motion.div>)
: ( : (
@@ -599,7 +625,7 @@ const TopNav: FC<Props> = ({ user, locales }) => {
key={`l-${ i }`} key={`l-${ i }`}
className="min-w-0 max-w-full" className="min-w-0 max-w-full"
transition={{ layout: topNavTransition }} transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}> layoutId={`${ activeSubmenuId }-${ i }`}>
<PrefetchLink <PrefetchLink
to={item.to} to={item.to}
target={item.to.slice (0, 2) === '//' target={item.to.slice (0, 2) === '//'
+2 -2
ファイルの表示
@@ -1,7 +1,7 @@
import type { FC } from 'react' import type { FC, ReactNode } from 'react'
type Props = { id?: string type Props = { id?: string
messages?: string[] } messages?: ReactNode[] }
export const FieldError: FC<Props> = ({ id, messages }: Props) => { export const FieldError: FC<Props> = ({ id, messages }: Props) => {
+3 -1
ファイルの表示
@@ -179,6 +179,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
closeRequest (active.id, active.kind !== 'confirm' && null) closeRequest (active.id, active.kind !== 'confirm' && null)
}}> }}>
<DialogContent <DialogContent
closeLabel={active.kind === 'confirm' ? active.options.closeLabel : undefined}
className={ className={
active.kind === 'form' active.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large' ? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
@@ -323,7 +324,8 @@ const DialogueProvider: FC<Props> = ({ children }) => {
if (!(open)) if (!(open))
closeNestedConfirm (false) closeNestedConfirm (false)
}}> }}>
<DialogContent className="px-6 pb-6 pt-7"> <DialogContent className="px-6 pb-6 pt-7"
closeLabel={nestedConfirm.options.closeLabel}>
<DialogHeader className="pl-8"> <DialogHeader className="pl-8">
<DialogTitle>{nestedConfirm.options.title}</DialogTitle> <DialogTitle>{nestedConfirm.options.title}</DialogTitle>
+3 -2
ファイルの表示
@@ -32,8 +32,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
& { closeLabel?: React.ReactNode }
>( >(
({ className, children, ...props }, ref) => ( ({ className, children, closeLabel = '閉ぢる', ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@@ -59,7 +60,7 @@ const DialogContent = React.forwardRef<
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50', 'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}> 'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/> <X className="h-4 w-4"/>
<span className="sr-only">閉ぢる</span> <span className="sr-only">{closeLabel}</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal>)) </DialogPortal>))
+29 -34
ファイルの表示
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings' import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings'
import { useUITranslation } from '@/lib/useUITranslation'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
@@ -22,7 +23,7 @@ type Props = {
type SegmentedOption<T extends string> = { type SegmentedOption<T extends string> = {
value: T value: T
label: string } label: ReactNode }
type SegmentedControlProps<T extends string> = { type SegmentedControlProps<T extends string> = {
value: T value: T
@@ -30,27 +31,18 @@ type SegmentedControlProps<T extends string> = {
onChange: (value: T) => void } onChange: (value: T) => void }
type SettingBlockProps = { type SettingBlockProps = {
title: string title: ReactNode
description?: string description?: ReactNode
children: ReactNode } children: ReactNode }
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [ const animationOptions: ClientAnimationMode[] = ['off', 'reduced', 'normal']
{ value: 'off', label: 'なし' },
{ value: 'reduced', label: '控えめ' },
{ value: 'normal', label: '通常' }]
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [ const embedAutoLoadOptions: ClientEmbedAutoLoadMode[] = ['off', 'manual', 'auto']
{ value: 'off', label: '読み込まない' },
{ value: 'manual', label: 'クリック時' },
{ value: 'auto', label: '自動' }]
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [ const linkPreloadOptions: ClientLinkPreloadMode[] = ['off', 'intent']
{ value: 'off', label: 'しない' },
{ value: 'intent', label: 'する' }] const tagRelationDisplayOptions: ClientTagRelationDisplayMode[] = ['flat', 'grouped']
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
{ value: 'flat', label: 'まとめない' },
{ value: 'grouped', label: 'まとめる' }]
const SegmentedControl = <T extends string,> ( const SegmentedControl = <T extends string,> (
{ value, options, onChange }: SegmentedControlProps<T>, { value, options, onChange }: SegmentedControlProps<T>,
@@ -92,10 +84,9 @@ const SettingBlock: FC<SettingBlockProps> = (
</div>) </div>)
const BehaviourSettingsSection: FC<Props> = ( const BehaviourSettingsSection: FC<Props> = props => {
{ sectionClassName, const { sectionClassName, onDirtyStateChange } = props
onDirtyStateChange }, const { label } = useUITranslation ('settings')
) => {
const [savedSettings, setSavedSettings] = const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ()) useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
const [draftSettings, setDraftSettings] = const [draftSettings, setDraftSettings] =
@@ -122,7 +113,7 @@ const BehaviourSettingsSection: FC<Props> = (
const next = setClientBehaviorSettings (draftSettings) const next = setClientBehaviorSettings (draftSettings)
setSavedSettings (next) setSavedSettings (next)
setDraftSettings (next) setDraftSettings (next)
toast ({ title: '動作設定を保存しました' }) toast ({ title: label ('behavior.saved') })
} }
const handleDiscard = () => { const handleDiscard = () => {
@@ -141,7 +132,7 @@ const BehaviourSettingsSection: FC<Props> = (
<section className={sectionClassName}> <section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-bold">動作</h2> <h2 className="text-xl font-bold">{label ('tabs.behavior')}</h2>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -150,45 +141,49 @@ const BehaviourSettingsSection: FC<Props> = (
variant="destructive" variant="destructive"
disabled={hasUnsavedChanges === false} disabled={hasUnsavedChanges === false}
onClick={handleDiscard}> onClick={handleDiscard}>
変更を破棄 {label ('actions.discard')}
</Button> </Button>
<Button <Button
type="button" type="button"
disabled={hasUnsavedChanges === false} disabled={hasUnsavedChanges === false}
onClick={handleSave}> onClick={handleSave}>
保存 {label ('actions.save')}
</Button> </Button>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingBlock title="アニメーション"> <SettingBlock title={label ('behavior.animation')}>
<SegmentedControl <SegmentedControl
value={draftSettings.animation ?? 'normal'} value={draftSettings.animation ?? 'normal'}
options={animationOptions} options={animationOptions.map (value => ({ value,
label: label (`behavior.animationOptions.${value}`) }))}
onChange={value => updateDraft ('animation', value)}/> onChange={value => updateDraft ('animation', value)}/>
</SettingBlock> </SettingBlock>
<SettingBlock <SettingBlock
title="リンク先の先読み" title={label ('behavior.preload')}
description="マウスを置いた時点でリンク先のデータを取得します。"> description={label ('behavior.preloadDescription')}>
<SegmentedControl <SegmentedControl
value={draftSettings.linkPreload ?? 'intent'} value={draftSettings.linkPreload ?? 'intent'}
options={linkPreloadOptions} options={linkPreloadOptions.map (value => ({ value,
label: label (`behavior.preloadOptions.${value}`) }))}
onChange={value => updateDraft ('linkPreload', value)}/> onChange={value => updateDraft ('linkPreload', value)}/>
</SettingBlock> </SettingBlock>
<SettingBlock title="タグの親子関係表示"> <SettingBlock title={label ('behavior.tagRelations')}>
<SegmentedControl <SegmentedControl
value={draftSettings.tagRelationDisplay ?? 'grouped'} value={draftSettings.tagRelationDisplay ?? 'grouped'}
options={tagRelationDisplayOptions} options={tagRelationDisplayOptions.map (value => ({ value,
label: label (`behavior.relationOptions.${value}`) }))}
onChange={value => updateDraft ('tagRelationDisplay', value)}/> onChange={value => updateDraft ('tagRelationDisplay', value)}/>
</SettingBlock> </SettingBlock>
<SettingBlock title="埋め込み自動読込"> <SettingBlock title={label ('behavior.embeds')}>
<SegmentedControl <SegmentedControl
value={draftSettings.embedAutoLoad ?? 'auto'} value={draftSettings.embedAutoLoad ?? 'auto'}
options={embedAutoLoadOptions} options={embedAutoLoadOptions.map (value => ({ value,
label: label (`behavior.embedOptions.${value}`) }))}
onChange={value => updateDraft ('embedAutoLoad', value)}/> onChange={value => updateDraft ('embedAutoLoad', value)}/>
</SettingBlock> </SettingBlock>
</div> </div>
+19 -13
ファイルの表示
@@ -1,8 +1,6 @@
import type { FC } from 'react'
import { useState } 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 { Button } from '@/components/ui/button'
import { Dialog, import { Dialog,
DialogContent, DialogContent,
@@ -12,6 +10,9 @@ import { Dialog,
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
import type { User } from '@/types' import type { User } from '@/types'
@@ -22,14 +23,17 @@ type Props = { visible: boolean
const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => { const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const { message, label } = useUITranslation ('settings')
const [inputCode, setInputCode] = useState ('') const [inputCode, setInputCode] = useState ('')
const handleTransfer = async () => { const handleTransfer = async () => {
if (!(await dialogue.confirm ({ if (!(await dialogue.confirm ({
title: '引継ぎを行ってもよろしいですか?', title: label ('transfer.confirm'),
description: '現在のアカウントからはログアウトされます.', description: label ('transfer.logoutWarning'),
confirmText: '引継ぐ', confirmText: label ('transfer.submit'),
cancelText: label ('actions.cancel'),
closeLabel: label ('actions.close'),
variant: 'danger' }))) variant: 'danger' })))
return return
@@ -41,29 +45,31 @@ const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
{ {
localStorage.setItem ('user_code', inputCode) localStorage.setItem ('user_code', inputCode)
setUser (data.user) setUser (data.user)
toast ({ title: '引継ぎ成功!' }) toast ({ title: label ('transfer.success') })
onVisibleChange (false) onVisibleChange (false)
} }
else else
toast ({ title: '認証失敗', description: 'そのコードは使へません.' }) toast ({ title: label ('transfer.authFailed'), description: label ('transfer.invalidCode') })
} }
catch catch
{ {
toast ({ title: '通信エラー', description: 'またあとで試してね.' }) toast ({ title: label ('transfer.networkError'), description: label ('transfer.retryLater') })
} }
} }
return ( return (
<Dialog open={visible} onOpenChange={onVisibleChange}> <Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent className="px-6 pp-6 pt-7"> <DialogContent className="px-6 pp-6 pt-7" closeLabel={label ('actions.close')}>
<DialogHeader className="pl-8"> <DialogHeader className="pl-8">
<DialogTitle>ほかのブラウザから引継ぐ</DialogTitle> <DialogTitle>{label ('account.inherit')}</DialogTitle>
<DialogDescription asChild> <DialogDescription asChild>
<div className="flex gap-2"> <div className="flex gap-2">
<Input placeholder="引継ぎコードを入力" <Input placeholder={message ('transfer.placeholder').text}
aria-label={message ('transfer.placeholder').text}
lang={message ('transfer.placeholder').language}
value={inputCode} value={inputCode}
onChange={ev => setInputCode (ev.target.value)}/> onChange={ev => setInputCode (ev.target.value)}/>
<Button onClick={handleTransfer}>引継ぐ</Button> <Button onClick={handleTransfer}>{label ('transfer.submit')}</Button>
</div> </div>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
+31 -26
ファイルの表示
@@ -8,14 +8,15 @@ import {
isSpaceBinding, isSpaceBinding,
keyBindingFromKeyboardEvent, keyBindingFromKeyboardEvent,
SHORTCUT_DEFINITIONS, SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts' } from '@/lib/keyboardShortcuts'
import { getEffectiveKeyBindings } from '@/lib/settings' import { getEffectiveKeyBindings } from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts' import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
import type { ShortcutActionId } from '@/lib/keyboardShortcuts' import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings' import type { ClientKeyboardSettings } from '@/lib/settings'
import type { FC } from 'react'
type Props = { type Props = {
sectionClassName: string sectionClassName: string
@@ -26,10 +27,9 @@ type Props = {
} }
const KeyboardSettingsSection: FC<Props> = ( const KeyboardSettingsSection: FC<Props> = props => {
{ sectionClassName, const { sectionClassName, onDirtyStateChange } = props
onDirtyStateChange }, const { label } = useUITranslation ('settings')
) => {
const { const {
keyboardSettings, keyboardSettings,
saveKeyboardSettings, saveKeyboardSettings,
@@ -92,7 +92,7 @@ const KeyboardSettingsSection: FC<Props> = (
if (isSpaceBinding (binding)) if (isSpaceBinding (binding))
{ {
toast ({ title: 'Space は今は割り当てられません.' }) toast ({ title: label ('shortcuts.spaceUnavailable') })
return return
} }
@@ -108,7 +108,7 @@ const KeyboardSettingsSection: FC<Props> = (
document.addEventListener ('keydown', onKeyDown, true) document.addEventListener ('keydown', onKeyDown, true)
return () => document.removeEventListener ('keydown', onKeyDown, true) return () => document.removeEventListener ('keydown', onKeyDown, true)
}, [capturingActionId]) }, [capturingActionId, label])
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({ const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
...definition, ...definition,
@@ -126,7 +126,7 @@ const KeyboardSettingsSection: FC<Props> = (
const handleSave = () => { const handleSave = () => {
saveKeyboardSettings (draftKeyboardSettings) saveKeyboardSettings (draftKeyboardSettings)
toast ({ title: 'キーボード設定を保存しました' }) toast ({ title: label ('shortcuts.saved') })
} }
const handleDiscard = () => { const handleDiscard = () => {
@@ -146,7 +146,7 @@ const KeyboardSettingsSection: FC<Props> = (
<section className={sectionClassName}> <section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-bold">キーボード</h2> <h2 className="text-xl font-bold">{label ('tabs.keyboard')}</h2>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -159,20 +159,20 @@ const KeyboardSettingsSection: FC<Props> = (
bindings: { }, bindings: { },
})) }))
}}> }}>
すべて既定に戻す {label ('actions.resetAll')}
</Button> </Button>
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
disabled={!(hasUnsavedChanges)} disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}> onClick={handleDiscard}>
変更を破棄 {label ('actions.discard')}
</Button> </Button>
<Button <Button
type="button" type="button"
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0} disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
onClick={handleSave}> onClick={handleSave}>
保存 {label ('actions.save')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -187,33 +187,35 @@ const KeyboardSettingsSection: FC<Props> = (
enabled: event.target.checked, enabled: event.target.checked,
})) }))
}}/> }}/>
<span>キーボード・ショートカットを有効にする</span> <span>{label ('shortcuts.enable')}</span>
</label> </label>
{capturingActionId != null && ( {capturingActionId != null && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます. {label ('shortcuts.captureHelp')}
</p>)} </p>)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[52rem] table-fixed border-collapse text-sm"> <table className="w-full min-w-[52rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border"> <thead className="border-b border-border">
<tr> <tr>
<th className="p-2 text-left">操作</th> <th className="p-2 text-left">{label ('shortcuts.operation')}</th>
<th className="p-2 text-left">適用範囲</th> <th className="p-2 text-left">{label ('shortcuts.scope')}</th>
<th className="p-2 text-left">現在のキー</th> <th className="p-2 text-left">{label ('shortcuts.currentKey')}</th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left">競合状態</th> <th className="p-2 text-left">{label ('shortcuts.conflictState')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map (row => ( {rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0"> <tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td> <td className="p-2">{label (`shortcuts.actions.${row.id}`)}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td> <td className="p-2">{label (`shortcuts.scopes.${row.scope}`)}</td>
<td className="p-2 font-mono"> <td className="p-2 font-mono">
{formatKeyBinding (row.currentBinding)} {row.currentBinding == null
? label ('shortcuts.unassigned')
: <span lang="en">{formatKeyBinding (row.currentBinding)}</span>}
</td> </td>
<td className="p-2"> <td className="p-2">
<Button <Button
@@ -225,7 +227,8 @@ const KeyboardSettingsSection: FC<Props> = (
capturingActionId === row.id ? null : row.id, capturingActionId === row.id ? null : row.id,
) )
}}> }}>
{capturingActionId === row.id ? '入力待ち…' : '変更'} {label (capturingActionId === row.id
? 'shortcuts.capturing' : 'actions.change')}
</Button> </Button>
</td> </td>
<td className="p-2"> <td className="p-2">
@@ -244,13 +247,15 @@ const KeyboardSettingsSection: FC<Props> = (
} }
}) })
}}> }}>
既定に戻す {label ('actions.reset')}
</Button> </Button>
</td> </td>
<td className="p-2"> <td className="p-2">
{row.hasConflict {row.hasConflict
? <span className="text-red-600 dark:text-red-400">競合あり</span> ? <span className="text-red-600 dark:text-red-400">
: 'なし'} {label ('shortcuts.conflicting')}
</span>
: label ('shortcuts.none')}
</td> </td>
</tr>))} </tr>))}
</tbody> </tbody>
+25 -32
ファイルの表示
@@ -5,19 +5,13 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { import { formatKeyBinding, SHORTCUT_DEFINITIONS } from '@/lib/keyboardShortcuts'
formatKeyBinding, import { useUITranslation } from '@/lib/useUITranslation'
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import type {
KeyBinding,
ShortcutActionId,
ShortcutScope,
} from '@/lib/keyboardShortcuts'
import type { FC } from 'react' import type { FC } from 'react'
import type { KeyBinding, ShortcutActionId, ShortcutScope } from '@/lib/keyboardShortcuts'
type Props = { type Props = {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
@@ -28,16 +22,10 @@ type Props = {
} }
const ShortcutHelpDialog: FC<Props> = ( const ShortcutHelpDialog: FC<Props> = props => {
{ const { open, onOpenChange, activeScope, effectiveBindings,
open, conflictActionIds, availableActionIds } = props
onOpenChange, const { label } = useUITranslation ('settings')
activeScope,
effectiveBindings,
conflictActionIds,
availableActionIds,
},
) => {
const rows = SHORTCUT_DEFINITIONS const rows = SHORTCUT_DEFINITIONS
.filter (definition => .filter (definition =>
definition.scope === 'global' || definition.scope === activeScope) definition.scope === 'global' || definition.scope === activeScope)
@@ -45,11 +33,12 @@ const ShortcutHelpDialog: FC<Props> = (
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl px-6 pb-6 pt-7"> <DialogContent className="max-w-3xl px-6 pb-6 pt-7"
closeLabel={label ('actions.close')}>
<DialogHeader className="pl-8"> <DialogHeader className="pl-8">
<DialogTitle>キーボード・ショートカット</DialogTitle> <DialogTitle>{label ('shortcuts.title')}</DialogTitle>
<DialogDescription> <DialogDescription>
この画面で利用できるショートカットを表示します。 {label ('shortcuts.description')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -57,24 +46,28 @@ const ShortcutHelpDialog: FC<Props> = (
<table className="w-full min-w-[40rem] table-fixed border-collapse text-sm"> <table className="w-full min-w-[40rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border"> <thead className="border-b border-border">
<tr> <tr>
<th className="p-2 text-left">操作</th> <th className="p-2 text-left">{label ('shortcuts.operation')}</th>
<th className="p-2 text-left">適用範囲</th> <th className="p-2 text-left">{label ('shortcuts.scope')}</th>
<th className="p-2 text-left">キー</th> <th className="p-2 text-left">{label ('shortcuts.key')}</th>
<th className="p-2 text-left">競合</th> <th className="p-2 text-left">{label ('shortcuts.conflict')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map (row => ( {rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0"> <tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td> <td className="p-2">{label (`shortcuts.actions.${row.id}`)}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td> <td className="p-2">{label (`shortcuts.scopes.${row.scope}`)}</td>
<td className="p-2 font-mono"> <td className="p-2 font-mono">
{formatKeyBinding (effectiveBindings[row.id])} {effectiveBindings[row.id] == null
? label ('shortcuts.unassigned')
: <span lang="en">{formatKeyBinding (effectiveBindings[row.id])}</span>}
</td> </td>
<td className="p-2"> <td className="p-2">
{conflictActionIds.has (row.id) {conflictActionIds.has (row.id)
? <span className="text-red-600 dark:text-red-400">競合あり</span> ? <span className="text-red-600 dark:text-red-400">
: 'なし'} {label ('shortcuts.conflicting')}
</span>
: label ('shortcuts.none')}
</td> </td>
</tr>))} </tr>))}
</tbody> </tbody>
+16 -12
ファイルの表示
@@ -1,6 +1,4 @@
import type { FC } from 'react' import { useDialogue } from '@/lib/dialogues/useDialogue'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, import { Dialog,
DialogContent, DialogContent,
@@ -10,6 +8,9 @@ import { Dialog,
DialogTitle } from '@/components/ui/dialog' DialogTitle } from '@/components/ui/dialog'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
import type { User } from '@/types' import type { User } from '@/types'
@@ -21,18 +22,21 @@ type Props = { visible: boolean
const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }) => { const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const { label } = useUITranslation ('settings')
const handleChange = async () => { const handleChange = async () => {
if (!(user)) if (!(user))
return return
if (!(await dialogue.confirm ({ if (!(await dialogue.confirm ({
title: '引継ぎコードを再発行しますか?', title: label ('userCode.confirm'),
description: ( description: (
<div> <div>
<p>再発行するとほかのブラウザからはログアウトされます.</p> <p>{label ('userCode.logoutWarning')}</p>
</div>), </div>),
confirmText: '再発行', confirmText: label ('userCode.confirmRenew'),
cancelText: label ('actions.cancel'),
closeLabel: label ('actions.close'),
variant: 'danger' }))) variant: 'danger' })))
return return
@@ -42,22 +46,22 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
{ {
localStorage.setItem ('user_code', data.code) localStorage.setItem ('user_code', data.code)
setUser (user => ({ ...user, inheritanceCode: data.code } as User)) setUser (user => ({ ...user, inheritanceCode: data.code } as User))
toast ({ title: '再発行しました.' }) toast ({ title: label ('userCode.renewed') })
} }
} }
return ( return (
<Dialog open={visible} onOpenChange={onVisibleChange}> <Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent className="px-6 pb-6 pt-7"> <DialogContent className="px-6 pb-6 pt-7" closeLabel={label ('actions.close')}>
<DialogHeader className="pl-8"> <DialogHeader className="pl-8">
<DialogTitle>引継ぎコード</DialogTitle> <DialogTitle>{label ('userCode.title')}</DialogTitle>
<DialogDescription asChild> <DialogDescription asChild>
<div> <div>
<p>あなたの引継ぎコードはこちらです:</p> <p>{label ('userCode.description')}</p>
<div className="m-2">{user?.inheritanceCode}</div> <div className="m-2">{user?.inheritanceCode}</div>
<p className="mt-1 text-sm text-destructive"> <p className="mt-1 text-sm text-destructive">
このコードはほかの人には教えないでください! {label ('userCode.secretWarning')}
</p> </p>
</div> </div>
</DialogDescription> </DialogDescription>
@@ -65,7 +69,7 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
<DialogFooter> <DialogFooter>
<Button onClick={handleChange} variant="destructive"> <Button onClick={handleChange} variant="destructive">
引継ぎコード再発行 {label ('userCode.renew')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+4 -3
ファイルの表示
@@ -4,10 +4,11 @@ import type { ReactNode } from 'react'
type DialogueVariant = 'default' | 'danger' type DialogueVariant = 'default' | 'danger'
type ConfirmOptions = { title: string type ConfirmOptions = { title: ReactNode
description?: ReactNode description?: ReactNode
confirmText?: string confirmText?: ReactNode
cancelText?: string cancelText?: ReactNode
closeLabel?: ReactNode
variant?: DialogueVariant } variant?: DialogueVariant }
type AlertOptions = { title: string type AlertOptions = { title: string
+45 -20
ファイルの表示
@@ -1,4 +1,5 @@
import { unlocalisedPath } from '@/lib/locales' import { unlocalisedPath } from '@/lib/locales'
import { i18n } from '@/lib/i18n'
export type ShortcutScope = export type ShortcutScope =
| 'global' | 'global'
@@ -39,85 +40,109 @@ export type ShortcutDefinition = {
} }
export const SHORTCUT_SCOPE_LABELS: Record<ShortcutScope, string> = { export const SHORTCUT_SCOPE_LABELS: Record<ShortcutScope, string> = {
global: '全画面', get global () { return i18n.t ('settings:shortcuts.scopes.global') },
posts: '広場', get posts () { return i18n.t ('settings:shortcuts.scopes.posts') },
postSearch: '広場検索', get postSearch () { return i18n.t ('settings:shortcuts.scopes.postSearch') },
tags: 'タグ', get tags () { return i18n.t ('settings:shortcuts.scopes.tags') },
settings: '設定', get settings () { return i18n.t ('settings:shortcuts.scopes.settings') },
wiki: 'Wiki', get wiki () { return i18n.t ('settings:shortcuts.scopes.wiki') },
materials: '素材', get materials () { return i18n.t ('settings:shortcuts.scopes.materials') },
} }
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
{ {
id: 'global.openShortcutHelp', id: 'global.openShortcutHelp',
label: 'ショートカット一覧を開く', get label () {
return i18n.t ('settings:shortcuts.actions.global.openShortcutHelp')
},
scope: 'global', scope: 'global',
defaultBinding: { key: '?', shift: true }, defaultBinding: { key: '?', shift: true },
}, },
{ {
id: 'global.focusSearch', id: 'global.focusSearch',
label: '検索欄へ移動', get label () {
return i18n.t ('settings:shortcuts.actions.global.focusSearch')
},
scope: 'global', scope: 'global',
defaultBinding: { key: '/' }, defaultBinding: { key: '/' },
}, },
{ {
id: 'navigation.posts', id: 'navigation.posts',
label: '広場へ移動', get label () {
return i18n.t ('settings:shortcuts.actions.navigation.posts')
},
scope: 'global', scope: 'global',
defaultBinding: { key: 'p' }, defaultBinding: { key: 'p' },
}, },
{ {
id: 'navigation.tags', id: 'navigation.tags',
label: 'タグへ移動', get label () {
return i18n.t ('settings:shortcuts.actions.navigation.tags')
},
scope: 'global', scope: 'global',
defaultBinding: { key: 't' }, defaultBinding: { key: 't' },
}, },
{ {
id: 'navigation.materials', id: 'navigation.materials',
label: '素材へ移動', get label () {
return i18n.t ('settings:shortcuts.actions.navigation.materials')
},
scope: 'global', scope: 'global',
defaultBinding: { key: 'm' }, defaultBinding: { key: 'm' },
}, },
{ {
id: 'navigation.wiki', id: 'navigation.wiki',
label: 'Wiki へ移動', get label () {
return i18n.t ('settings:shortcuts.actions.navigation.wiki')
},
scope: 'global', scope: 'global',
defaultBinding: { key: 'w' }, defaultBinding: { key: 'w' },
}, },
{ {
id: 'pagination.next', id: 'pagination.next',
label: '次のページへ移動', get label () {
return i18n.t ('settings:shortcuts.actions.pagination.next')
},
scope: 'global', scope: 'global',
defaultBinding: { key: 'ArrowRight' }, defaultBinding: { key: 'ArrowRight' },
}, },
{ {
id: 'pagination.previous', id: 'pagination.previous',
label: '前のページへ移動', get label () {
return i18n.t ('settings:shortcuts.actions.pagination.previous')
},
scope: 'global', scope: 'global',
defaultBinding: { key: 'ArrowLeft' }, defaultBinding: { key: 'ArrowLeft' },
}, },
{ {
id: 'settings.account', id: 'settings.account',
label: '設定: アカウント', get label () {
return i18n.t ('settings:shortcuts.actions.settings.account')
},
scope: 'settings', scope: 'settings',
defaultBinding: { key: '1' }, defaultBinding: { key: '1' },
}, },
{ {
id: 'settings.behavior', id: 'settings.behavior',
label: '設定: 動作', get label () {
return i18n.t ('settings:shortcuts.actions.settings.behavior')
},
scope: 'settings', scope: 'settings',
defaultBinding: { key: '2' }, defaultBinding: { key: '2' },
}, },
{ {
id: 'settings.theme', id: 'settings.theme',
label: '設定: テーマ', get label () {
return i18n.t ('settings:shortcuts.actions.settings.theme')
},
scope: 'settings', scope: 'settings',
defaultBinding: { key: '3' }, defaultBinding: { key: '3' },
}, },
{ {
id: 'settings.keyboard', id: 'settings.keyboard',
label: '設定: キーボード', get label () {
return i18n.t ('settings:shortcuts.actions.settings.keyboard')
},
scope: 'settings', scope: 'settings',
defaultBinding: { key: '4' }, defaultBinding: { key: '4' },
}, },
@@ -251,7 +276,7 @@ const keyLabel = (key: string): string => {
export const formatKeyBinding = (binding: KeyBinding | null): string => { export const formatKeyBinding = (binding: KeyBinding | null): string => {
if (binding == null) if (binding == null)
return '未割当' return i18n.t ('settings:shortcuts.unassigned')
return [ return [
binding.ctrl ? 'Ctrl' : null, binding.ctrl ? 'Ctrl' : null,
+3 -1
ファイルの表示
@@ -131,7 +131,9 @@ export const wikiKeys = {
title: string, title: string,
p: { version?: string }, p: { version?: string },
locale: LocaleCode = 'ja', 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 = { export const helpKeys = {
root: ['help'] as const, root: ['help'] as const,
+7 -3
ファイルの表示
@@ -10,6 +10,7 @@ import {
import { useBlocker } from 'react-router-dom' import { useBlocker } from 'react-router-dom'
import { useDialogue } from '@/lib/dialogues/useDialogue' import { useDialogue } from '@/lib/dialogues/useDialogue'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, PropsWithChildren } from 'react' import type { FC, PropsWithChildren } from 'react'
@@ -34,6 +35,7 @@ const UnsavedChangesGuardContext =
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => { export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const { label } = useUITranslation ('settings')
const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ()) const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
const bypassNextNavigationRef = useRef<symbol | null> (null) const bypassNextNavigationRef = useRef<symbol | null> (null)
const [revision, setRevision] = useState (0) const [revision, setRevision] = useState (0)
@@ -99,8 +101,10 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
try try
{ {
const confirmed = await dialogue.confirm ({ const confirmed = await dialogue.confirm ({
title: '変更が破棄してページ移動しますか?', title: label ('unsaved.leavePage'),
confirmText: '変更を破棄して移動', confirmText: label ('unsaved.discardAndMove'),
cancelText: label ('actions.cancel'),
closeLabel: label ('actions.close'),
variant: 'danger' }) variant: 'danger' })
if (!(confirmed)) if (!(confirmed))
return false return false
@@ -111,7 +115,7 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
{ {
dialogueOpenRef.current = false dialogueOpenRef.current = false
} }
}, [dialogue, hasUnsavedChanges]) }, [dialogue, hasUnsavedChanges, label])
useEffect (() => { useEffect (() => {
if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current) if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
+36 -15
ファイルの表示
@@ -1,6 +1,8 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import { i18n } from '@/lib/i18n'
import type { ClassValue } from 'clsx' import type { ClassValue } from 'clsx'
import type { LocaleCode } from '@/lib/locales' import type { LocaleCode } from '@/lib/locales'
@@ -27,67 +29,86 @@ export const dateString = (
second: unknown == null ? 'numeric' : undefined }) 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') if (locale === 'ja')
return dateString (value) return dateString (value, unknown)
const date = toDate (value) const date = toDate (value)
if (Number.isNaN (date.getTime ())) if (Number.isNaN (date.getTime ()))
return dateString (value) return dateString (value, unknown)
const parts = new Intl.DateTimeFormat ('en-CA', { const parts = new Intl.DateTimeFormat ('en-CA', {
year: 'numeric', month: '2-digit', day: '2-digit', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23' }).formatToParts (date) hourCycle: 'h23' }).formatToParts (date)
const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? '' const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? ''
return `${ part ('year') }-${ part ('month') }-${ part ('day') }\u3000${ const omitDay = unknown != null && ['month', 'day'].includes (unknown)
part ('hour') }:${ part ('minute') }:${ part ('second') }` 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 = ( export const originalCreatedAtString = (
f: string | Date | null, f: string | Date | null,
b: string | Date | null): string => { b: string | Date | null,
locale: LocaleCode = 'ja',
): string => {
const from = f ? toDate (f) : null const from = f ? toDate (f) : null
const before = b ? toDate (b) : 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) if (from && before)
{ {
const diff = before.getTime () - from.getTime () const diff = before.getTime () - from.getTime ()
if (diff <= 60_000 /* 1 分 */) if (diff <= 60_000 /* 1 分 */)
return dateString (from, 'second') return format (from, 'second')
if (from.getMinutes () === 0 && before.getMinutes () === 0) if (from.getMinutes () === 0 && before.getMinutes () === 0)
{ {
if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000) 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 (from.getHours () === 0 && before.getHours () === 0)
{ {
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000) 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 (from.getDate () === 1 && before.getDate () === 1)
{ {
if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */) if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */)
return dateString (from, 'day') + ' (日不詳)' return `${format (from, 'day')}${space}(${unknownLabel ('unknownDay')})`
if (from.getMonth () === 0 && before.getMonth () === 0 if (from.getMonth () === 0 && before.getMonth () === 0
&& (31_449_600_000 /* 364 日 */ <= diff && (31_449_600_000 /* 364 日 */ <= diff
&& diff < 31_708_800_000 /* 367 日 */)) && 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) .filter (Boolean)
.join (' ')) .join (space))
return rtn === '〜' ? '年月日不詳' : rtn return rtn === '〜' ? unknownLabel ('unknownDate') : rtn
} }
+7 -1
ファイルの表示
@@ -3,7 +3,13 @@ import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales' 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 ( export const fetchWikiPages = async (
+37
ファイルの表示
@@ -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": "年月日不詳"
}
+142
ファイルの表示
@@ -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": "設定: キーボード"
}
}
}
}
+1 -1
ファイルの表示
@@ -26,7 +26,7 @@
"resultsCount": "{{count}} ペ", "resultsCount": "{{count}} ペ",
"menu": "メニュー", "menu": "メニュー",
"anonymous": "レサㇰ ユㇰ ニチカ ウタㇻ", "anonymous": "レサㇰ ユㇰ ニチカ ウタㇻ",
"bot": "bot カㇻ", "bot": "Bot カㇻ",
"deprecatedSuffix": "(エイワンケ ソモ エアㇱカイ)", "deprecatedSuffix": "(エイワンケ ソモ エアㇱカイ)",
"development": "カㇻ コㇿ アン シㇼキ" }, "development": "カㇻ コㇿ アン シㇼキ" },
"actions": { "actions": {
+30
ファイルの表示
@@ -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": "年月日不詳"
}
+125
ファイルの表示
@@ -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": "設定: キーボード"
}
}
}
}
+5 -4
ファイルの表示
@@ -14,14 +14,15 @@ import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import { helpKeys } from '@/lib/queryKeys' import { helpKeys } from '@/lib/queryKeys'
import { LIST_LIMIT_OPTIONS } from '@/lib/settings' import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
import { dateString } from '@/lib/utils' import { localisedDateString } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
const HelpChangesPage: FC = () => { const HelpChangesPage: FC = () => {
const { message, label } = useUITranslation ('help') const { message, label } = useUITranslation ('help')
const language = localeLanguage (useLocale ()) const locale = useLocale ()
const language = localeLanguage (locale)
const [searchParams, setSearchParams] = useSearchParams () const [searchParams, setSearchParams] = useSearchParams ()
const identifier = searchParams.get ('identifier') ?? '' const identifier = searchParams.get ('identifier') ?? ''
const pageValue = searchParams.get ('page') ?? '1' const pageValue = searchParams.get ('page') ?? '1'
@@ -72,7 +73,7 @@ const HelpChangesPage: FC = () => {
{isLoading {isLoading
? <p className="mt-4">{label ('shared:messages.loading')}</p> ? <p className="mt-4">{label ('shared:messages.loading')}</p>
: (error : (error
? <p className="mt-4 text-red-600" role="alert">{error.message}</p> ? <p className="mt-4 text-red-600" role="alert">{label ('common:fetchError')}</p>
: ( : (
<div className="mt-4"> <div className="mt-4">
<div className="min-w-0 max-w-full overflow-x-auto"> <div className="min-w-0 max-w-full overflow-x-auto">
@@ -109,7 +110,7 @@ const HelpChangesPage: FC = () => {
</PrefetchLink> </PrefetchLink>
</td> </td>
<td className="p-2"> <td className="p-2">
{updatedAt != null && dateString (updatedAt, 'hour')} {updatedAt != null && localisedDateString (updatedAt, locale, 'hour')}
</td> </td>
</tr>) </tr>)
})} })}
+29 -19
ファイルの表示
@@ -14,12 +14,14 @@ import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchMaterialChanges } from '@/lib/materials' import { fetchMaterialChanges } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' 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' import type { FC, FormEvent } from 'react'
const MaterialHistoryPage: FC = () => { const MaterialHistoryPage: FC = () => {
const { message, label } = useUITranslation ('history')
const locale = useLocale () const locale = useLocale ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
@@ -83,11 +85,13 @@ const MaterialHistoryPage: FC = () => {
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
<title>{`素材履歴 | ${ SITE_TITLE }`}</title> <title lang={message ('materialTitle').language}>
{message ('materialTitle').text} | {SITE_TITLE}
</title>
</Helmet> </Helmet>
<div className="space-y-5"> <div className="space-y-5">
<PageTitle>素材履歴</PageTitle> <PageTitle>{label ('materialTitle')}</PageTitle>
<form <form
onSubmit={handleSearch} onSubmit={handleSearch}
@@ -95,20 +99,24 @@ const MaterialHistoryPage: FC = () => {
text-stone-900 dark:border-stone-700 dark:bg-stone-900 text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100"> dark:text-stone-100">
<div className="space-y-3"> <div className="space-y-3">
<FormField label="素材 ID"> <FormField label={label ('materialId')}>
{({ invalid }) => ( {({ invalid }) => (
<input <input
type="text" type="text"
value={materialIdInput} value={materialIdInput}
aria-label={message ('materialId').text}
lang={message ('materialId').language}
onChange={e => setMaterialIdInput (e.target.value)} onChange={e => setMaterialIdInput (e.target.value)}
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
<FormField label="タグ名"> <FormField label={label ('tagName')}>
{({ invalid }) => ( {({ invalid }) => (
<input <input
type="text" type="text"
value={tagInput} value={tagInput}
aria-label={message ('tagName').text}
lang={message ('tagName').language}
onChange={e => setTagInput (e.target.value)} onChange={e => setTagInput (e.target.value)}
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
@@ -119,16 +127,16 @@ const MaterialHistoryPage: FC = () => {
className="mt-4 rounded-full border border-stone-300 bg-white px-4 py-2 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 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"> dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
絞り込む {label ('filter')}
</button> </button>
</form> </form>
{isLoading {isLoading
? 'Loading...' ? label ('common:loading')
: isError : (isError
? ( ? (
<p className="text-red-600 dark:text-red-300"> <p className="text-red-600 dark:text-red-300" role="alert">
素材履歴の取得に失敗しました. {label ('materialError')}
</p>) </p>)
: ( : (
<> <>
@@ -147,14 +155,14 @@ const MaterialHistoryPage: FC = () => {
<thead className="border-b-2 border-black dark:border-white"> <thead className="border-b-2 border-black dark:border-white">
<tr> <tr>
<th className="p-2 text-left">日時</th> <th className="p-2 text-left">{label ('wiki:dateTime')}</th>
<th className="p-2 text-left">素材 ID</th> <th className="p-2 text-left">{label ('materialId')}</th>
<th className="p-2 text-left">版</th> <th className="p-2 text-left">{label ('version')}</th>
<th className="p-2 text-left">タグ名</th> <th className="p-2 text-left">{label ('tagName')}</th>
<th className="p-2 text-left">ファイル名</th> <th className="p-2 text-left">{label ('filename')}</th>
<th className="p-2 text-left">URL</th> <th className="p-2 text-left">URL</th>
<th className="p-2 text-left">export_path</th> <th className="p-2 text-left">export_path</th>
<th className="p-2 text-left">操作者</th> <th className="p-2 text-left">{label ('actor')}</th>
</tr> </tr>
</thead> </thead>
@@ -163,7 +171,9 @@ const MaterialHistoryPage: FC = () => {
<tr <tr
key={version.id} key={version.id}
className="even:bg-gray-100 dark:even:bg-gray-700"> className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">{dateString (version.createdAt)}</td> <td className="p-2">
{localisedDateString (version.createdAt, locale)}
</td>
<td className="p-2"> <td className="p-2">
<PrefetchLink to={`/materials/${ version.materialId }`}> <PrefetchLink to={`/materials/${ version.materialId }`}>
#{version.materialId} #{version.materialId}
@@ -179,7 +189,7 @@ const MaterialHistoryPage: FC = () => {
<td className="p-2"> <td className="p-2">
{version.createdByUser {version.createdByUser
? version.createdByUser.name || `#${ version.createdByUser.id }` ? version.createdByUser.name || `#${ version.createdByUser.id }`
: 'bot 操作'} : label ('bot')}
</td> </td>
</tr>))} </tr>))}
</tbody> </tbody>
@@ -187,7 +197,7 @@ const MaterialHistoryPage: FC = () => {
</div> </div>
<Pagination page={page} totalPages={totalPages}/> <Pagination page={page} totalPages={totalPages}/>
</>)} </>))}
</div> </div>
</MainArea>) </MainArea>)
} }
+49 -41
ファイルの表示
@@ -9,7 +9,7 @@ import TagLink from '@/components/TagLink'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
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 { useDialogue } from '@/components/dialogues/DialogueProvider' import { useDialogue } from '@/lib/dialogues/useDialogue'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
@@ -19,7 +19,8 @@ import { fetchPostChanges, updatePost } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchExternalTag, fetchTag } from '@/lib/tags' import { fetchExternalTag, fetchTag } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' 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' import type { FC, MouseEvent } from 'react'
@@ -40,6 +41,7 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
const PostHistoryPage: FC = () => { const PostHistoryPage: FC = () => {
const { message, label } = useUITranslation ('history')
const locale = useLocale () const locale = useLocale ()
const dialogue = useDialogue () const dialogue = useDialogue ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
@@ -72,7 +74,7 @@ const PostHistoryPage: FC = () => {
queryKey: tagsKeys.externalShow (externalTagQueryId, locale), queryKey: tagsKeys.externalShow (externalTagQueryId, locale),
queryFn: () => fetchExternalTag (externalTagQueryId, locale) }) queryFn: () => fetchExternalTag (externalTagQueryId, locale) })
const { data, isLoading: loading } = useQuery ({ const { data, isLoading: loading, isError } = useQuery ({
queryKey: postsKeys.changes ({ ...(id && { post: id }), queryKey: postsKeys.changes ({ ...(id && { post: id }),
...(tagId && { tag: tagId }), ...(tagId && { tag: tagId }),
...(externalTagId && { externalTag: externalTagId }), ...(externalTagId && { externalTag: externalTagId }),
@@ -85,7 +87,8 @@ const PostHistoryPage: FC = () => {
const changes = data?.versions ?? [] const changes = data?.versions ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0
const displayedTag = externalTag ?? tag const displayedTag = externalTag ?? tag
const pageTitleLabel = `耕作履歴${ id ? `: 投稿 #${ id }` : '' }${ const pageTitleLabel = `${message ('postTitle').text}${
id ? `${message ('postPrefix').text}#${id}` : '' }${
displayedTag ? `(${ displayedTag.name })` : '' }` displayedTag ? `(${ displayedTag.name })` : '' }`
const qc = useQueryClient () const qc = useQueryClient ()
@@ -94,27 +97,24 @@ const PostHistoryPage: FC = () => {
e.preventDefault () e.preventDefault ()
if (!(await dialogue.confirm ({ if (!(await dialogue.confirm ({
title: '差戻の確認', title: label ('confirmTitle'),
description: `『${ change.title.current || change.url.current }』を版 ${ description: label ('confirmPost', {
change.versionNo } に差戻します.\nよろしいですか?`, name: change.title.current || change.url.current, version: change.versionNo }),
confirmText: '差戻' }))) confirmText: label ('revert'),
cancelText: label ('settings:actions.cancel'),
closeLabel: label ('settings:actions.close') })))
return return
try try
{ {
const id = change.postId const id = change.postId
const title = change.title.current const title = change.title.current
const tags = const currentTags = change.tags.filter (tag => tag.type !== 'removed' && !(tag.external))
change.tags if (currentTags.some (tag => tag.restoreName == null)
.filter (t => t.type !== 'removed') || typeof change.parentPostIds !== 'string')
.map (t => t.name) throw new Error ()
.filter (t => t.slice (0, 5) !== 'nico:') const tags = currentTags.map (tag => tag.restoreName).join (' ')
.join (' ') const parentPostIds = change.parentPostIds
const parentPostIds =
(change.parentPosts ?? [])
.filter (p => p.type !== 'removed')
.map (p => p.id)
.join (' ')
const duration = const duration =
change.videoMs.current == null change.videoMs.current == null
? null ? null
@@ -128,11 +128,11 @@ const PostHistoryPage: FC = () => {
qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '差戻しました.' }) toast ({ description: label ('restored') })
} }
catch catch
{ {
toast ({ description: '差戻に失敗……' }) toast ({ description: label ('restoreFailed') })
} }
} }
@@ -146,12 +146,17 @@ const PostHistoryPage: FC = () => {
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
<title>{`耕作履歴 | ${ SITE_TITLE }`}</title> <title lang={message ('postTitle').language}>
{message ('postTitle').text} | {SITE_TITLE}
</title>
</Helmet> </Helmet>
<PageTitle aria-label={pageTitleLabel}> <PageTitle aria-label={pageTitleLabel} lang={message ('postTitle').language}>
耕作履歴 {label ('postTitle')}
{id && <>: 投稿 {<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>}</>} {id && (
<>{label ('postPrefix')}
<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>
</>)}
{displayedTag && ( {displayedTag && (
<>(<TagLink <>(<TagLink
tag={displayedTag} tag={displayedTag}
@@ -159,7 +164,8 @@ const PostHistoryPage: FC = () => {
withCount={false}/>)</>)} withCount={false}/>)</>)}
</PageTitle> </PageTitle>
{loading ? 'Loading...' : ( {isError && <p role="alert">{label ('common:fetchError')}</p>}
{loading ? label ('common:loading') : (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[1200px] table-fixed border-collapse"> <table className="w-full min-w-[1200px] table-fixed border-collapse">
@@ -186,15 +192,15 @@ const PostHistoryPage: FC = () => {
<thead className="border-b-2 border-black dark:border-white"> <thead className="border-b-2 border-black dark:border-white">
<tr> <tr>
<th className="p-2 text-left">投稿</th> <th className="p-2 text-left">{label ('post')}</th>
<th className="p-2 text-left">版</th> <th className="p-2 text-left">{label ('version')}</th>
<th className="p-2 text-left">タイトル</th> <th className="p-2 text-left">{label ('shared:labels.title')}</th>
<th className="p-2 text-left">URL</th> <th className="p-2 text-left">URL</th>
<th className="p-2 text-left">タグ</th> <th className="p-2 text-left">{label ('navigation:tags')}</th>
{/* TODO: 親投稿の履歴 */} {/* TODO: 親投稿の履歴 */}
{/* <th className="p-2 text-left">親投稿</th> */} {/* <th className="p-2 text-left">親投稿</th> */}
<th className="p-2 text-left">オリジナルの投稿日時</th> <th className="p-2 text-left">{label ('originalPostedAt')}</th>
<th className="p-2 text-left">更新日時</th> <th className="p-2 text-left">{label ('tags:updatedAt')}</th>
<th className="p-2"/> <th className="p-2"/>
</tr> </tr>
</thead> </thead>
@@ -286,32 +292,34 @@ const PostHistoryPage: FC = () => {
{pp.title} {pp.title}
</span>))))} </span>))))}
</td> */} </td> */}
<td className="p-2"> <td className="p-2"
lang={message ('unknownDate').language}>
{change.versionNo === 1 {change.versionNo === 1
? originalCreatedAtString (change.originalCreatedFrom.current, ? originalCreatedAtString (
change.originalCreatedBefore.current) change.originalCreatedFrom.current,
change.originalCreatedBefore.current, locale)
: renderDiff ({ : renderDiff ({
current: originalCreatedAtString ( current: originalCreatedAtString (
change.originalCreatedFrom.current, change.originalCreatedFrom.current,
change.originalCreatedBefore.current), change.originalCreatedBefore.current, locale),
prev: originalCreatedAtString ( prev: originalCreatedAtString (
change.originalCreatedFrom.prev, change.originalCreatedFrom.prev,
change.originalCreatedBefore.prev) })} change.originalCreatedBefore.prev, locale) })}
</td> </td>
<td className="p-2"> <td className="p-2">
{change.createdByUser {change.createdByUser
? ( ? (
<PrefetchLink to={`/users/${ change.createdByUser.id }`}> <PrefetchLink to={`/users/${ change.createdByUser.id }`}>
{change.createdByUser.name {change.createdByUser.name
|| `名もなきニジラー(#${ change.createdByUser.id })`} || label ('anonymous', { id: change.createdByUser.id })}
</PrefetchLink>) </PrefetchLink>)
: 'bot 操作'} : label ('bot')}
<br/> <br/>
{dateString (change.createdAt)} {localisedDateString (change.createdAt, locale)}
</td> </td>
<td className="p-2"> <td className="p-2">
<a href="#" onClick={async e => await handleRevert (e, change)}> <a href="#" onClick={async e => await handleRevert (e, change)}>
復元 {label ('shared:actions.restore')}
</a> </a>
</td> </td>
</tr>) </tr>)
+55 -41
ファイルの表示
@@ -10,42 +10,40 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORY_NAMES } from '@/consts'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import { clientScrollBehaviour } from '@/lib/clientAnimation' import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTagChanges } from '@/lib/tags' import { fetchTagChanges } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' 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.current
? ( ? (
<> <>
<del className="text-red-600 dark:text-red-400"> <del className="text-red-600 dark:text-red-400">
{diff.prev && <>{diff.prev}<br/></>} {diff.prev && <>{format (diff.prev)}<br/></>}
</del> </del>
<ins className="text-green-600 dark:text-green-400"> <ins className="text-green-600 dark:text-green-400">
{diff.current} {diff.current != null && format (diff.current)}
</ins> </ins>
</>) </>)
: 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 TagHistoryPage: FC = () => {
const { message, label } = useUITranslation ('history')
const dialogue = useDialogue ()
const locale = useLocale () const locale = useLocale ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
@@ -55,7 +53,7 @@ const TagHistoryPage: FC = () => {
const page = Number (query.get ('page') ?? 1) const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20) 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), queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }, locale),
queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }, locale) }) queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }, locale) })
const changes = data?.versions ?? [] const changes = data?.versions ?? []
@@ -71,15 +69,21 @@ const TagHistoryPage: FC = () => {
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
<title>{`タグ定義変更履歴 | ${ SITE_TITLE }`}</title> <title lang={message ('tagTitle').language}>
{message ('tagTitle').text} | {SITE_TITLE}
</title>
</Helmet> </Helmet>
<PageTitle> <PageTitle>
タグ定義変更履歴 {label ('tagTitle')}
{id && <>: タグ {<PrefetchLink to={`/tags/${ id }`}>#{id}</PrefetchLink>}</>} {id && (
<>{label ('tagPrefix')}
<PrefetchLink to={`/tags/${ id }`}>#{id}</PrefetchLink>
</>)}
</PageTitle> </PageTitle>
{loading ? 'Loading...' : ( {isError && <p role="alert">{label ('common:fetchError')}</p>}
{loading ? label ('common:loading') : (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[1200px] table-fixed border-collapse"> <table className="w-full min-w-[1200px] table-fixed border-collapse">
@@ -104,13 +108,13 @@ const TagHistoryPage: FC = () => {
<thead className="border-b-2 border-black dark:border-white"> <thead className="border-b-2 border-black dark:border-white">
<tr> <tr>
<th className="p-2 text-left">版</th> <th className="p-2 text-left">{label ('version')}</th>
<th className="p-2 text-left">名称</th> <th className="p-2 text-left">{label ('name')}</th>
<th className="p-2 text-left">カテゴリ</th> <th className="p-2 text-left">{label ('common:categoryLabel')}</th>
<th className="p-2 text-left">状態</th> <th className="p-2 text-left">{label ('common:stateLabel')}</th>
<th className="p-2 text-left">別名</th> <th className="p-2 text-left">{label ('tags:aliases')}</th>
<th className="p-2 text-left">上位タグ</th> <th className="p-2 text-left">{label ('tags:parents')}</th>
<th className="p-2 text-left">更新日時</th> <th className="p-2 text-left">{label ('tags:updatedAt')}</th>
<th className="p-2"/> <th className="p-2"/>
</tr> </tr>
</thead> </thead>
@@ -122,13 +126,14 @@ const TagHistoryPage: FC = () => {
<td className="p-2">{change.tagId}.{change.versionNo}</td> <td className="p-2">{change.tagId}.{change.versionNo}</td>
<td className="p-2 break-all">{renderDiff (change.name)}</td> <td className="p-2 break-all">{renderDiff (change.name)}</td>
<td className="p-2 break-all"> <td className="p-2 break-all">
{renderDiff ({ {renderDiff (change.category,
current: CATEGORY_NAMES[change.category.current], category => label (`common:categories.${category}`))}
prev: (change.category.prev
&& CATEGORY_NAMES[change.category.prev]) })}
</td> </td>
<td className="p-2 break-all"> <td className="p-2 break-all">
{renderStateDiff (change.deprecatedAt)} {renderDiff ({
current: change.deprecatedAt.current ? 'deprecated' : '',
prev: change.deprecatedAt.prev ? 'deprecated' : '' },
state => state ? label (`common:states.${state}`) : '')}
</td> </td>
<td className="p-2"> <td className="p-2">
{change.aliases.map ((tag, i) => ( {change.aliases.map ((tag, i) => (
@@ -179,11 +184,11 @@ const TagHistoryPage: FC = () => {
? ( ? (
<PrefetchLink to={`/users/${ change.createdByUser.id }`}> <PrefetchLink to={`/users/${ change.createdByUser.id }`}>
{change.createdByUser.name {change.createdByUser.name
|| `名もなきニジラー(#${ change.createdByUser.id })`} || label ('anonymous', { id: change.createdByUser.id })}
</PrefetchLink>) </PrefetchLink>)
: 'bot 操作'} : label ('bot')}
<br/> <br/>
{dateString (change.createdAt)} {localisedDateString (change.createdAt, locale)}
</td> </td>
<td className="p-2"> <td className="p-2">
<a <a
@@ -191,13 +196,22 @@ const TagHistoryPage: FC = () => {
onClick={async e => { onClick={async e => {
e.preventDefault () e.preventDefault ()
if (!(confirm ( if (!(await dialogue.confirm ({
`タグ『${ change.name.current }』を版 ${ title: label ('confirmTitle'),
change.versionNo } に差戻します.\nよろしいですか?`))) description: label ('confirmTag', {
name: change.name.current ?? '',
version: change.versionNo }),
confirmText: label ('revert'),
cancelText: label ('settings:actions.cancel'),
closeLabel: label ('settings:actions.close') })))
return return
try try
{ {
if (!(change.name.current)
|| change.parentTags.some (tag =>
tag.type !== 'removed' && !(tag.tag.name)))
throw new Error ()
await apiPut ( await apiPut (
`/tags/${ change.tagId }`, `/tags/${ change.tagId }`,
{ name: change.name.current, { name: change.name.current,
@@ -216,14 +230,14 @@ const TagHistoryPage: FC = () => {
qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '差戻しました.' }) toast ({ description: label ('restored') })
} }
catch catch
{ {
toast ({ description: '差戻に失敗……' }) toast ({ description: label ('restoreFailed') })
} }
}}> }}>
復元 {label ('shared:actions.restore')}
</a> </a>
</td> </td>
</tr>))} </tr>))}
+130 -114
ファイルの表示
@@ -2,23 +2,24 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useBeforeUnload, useLocation } from 'react-router-dom' 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 FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label' import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import { useDialogue } from '@/lib/dialogues/useDialogue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection' import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection'
import InheritDialogue from '@/components/users/InheritDialogue' import InheritDialogue from '@/components/users/InheritDialogue'
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection' import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
import UserCodeDialogue from '@/components/users/UserCodeDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue'
import { CATEGORY_NAMES, CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { i18n } from '@/lib/i18n'
import { import {
applyClientAppearance, applyClientAppearance,
applyThemeAppearanceState, applyThemeAppearanceState,
@@ -41,8 +42,9 @@ import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { cn, inputClass } from '@/lib/utils' import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' 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 { Category, Tag, User } from '@/types'
import type { import type {
@@ -85,7 +87,6 @@ type ThemeModeItem = {
type ThemeSelectionItem = { type ThemeSelectionItem = {
id: UserThemeSlotSelection id: UserThemeSlotSelection
label: string
baseTheme: BuiltinThemeId baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo } slotNo: UserThemeSlotNo }
@@ -129,7 +130,7 @@ type ThemeDraftState = {
type ThemeSegmentedOption<T extends string | number> = { type ThemeSegmentedOption<T extends string | number> = {
value: T value: T
label: string } label: ReactNode }
type ThemeSegmentedControlProps<T extends string | number> = { type ThemeSegmentedControlProps<T extends string | number> = {
value: T value: T
@@ -137,15 +138,15 @@ type ThemeSegmentedControlProps<T extends string | number> = {
onChange: (value: T) => void } onChange: (value: T) => void }
const tabs: TabSpec[] = [ const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' }, { id: 'account', label: 'tabs.account' },
{ id: 'behavior', label: '動作' }, { id: 'behavior', label: 'tabs.behavior' },
{ id: 'theme', label: 'テーマ' }, { id: 'theme', label: 'tabs.theme' },
{ id: 'keyboard', label: 'キーボード' } ] { id: 'keyboard', label: 'tabs.keyboard' }]
const themeModeSelections: ThemeModeItem[] = [ const themeModeSelections: ThemeModeItem[] = [
{ id: 'system', label: 'システム設定に従う' }, { id: 'system', label: 'theme.modes.system' },
{ id: 'light', label: 'ライト' }, { id: 'light', label: 'theme.modes.light' },
{ id: 'dark', label: 'ダーク' } ] { id: 'dark', label: 'theme.modes.dark' }]
const themeSlotNoSelections: ThemeSegmentedOption<UserThemeSlotNo>[] = [ const themeSlotNoSelections: ThemeSegmentedOption<UserThemeSlotNo>[] = [
{ value: 1, label: '1' }, { value: 1, label: '1' },
@@ -156,20 +157,19 @@ const sectionClassName =
'space-y-4 rounded-xl p-4' 'space-y-4 rounded-xl p-4'
const themeSelections: ThemeSelectionItem[] = [ const themeSelections: ThemeSelectionItem[] = [
{ id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 }, { id: 'user:light:1', baseTheme: 'light', slotNo: 1 },
{ id: 'user:light:2', label: 'ライト 2', baseTheme: 'light', slotNo: 2 }, { id: 'user:light:2', baseTheme: 'light', slotNo: 2 },
{ id: 'user:light:3', label: 'ライト 3', baseTheme: 'light', slotNo: 3 }, { id: 'user:light:3', baseTheme: 'light', slotNo: 3 },
{ id: 'user:dark:1', label: 'ダーク 1', baseTheme: 'dark', slotNo: 1 }, { id: 'user:dark:1', baseTheme: 'dark', slotNo: 1 },
{ id: 'user:dark:2', label: 'ダーク 2', baseTheme: 'dark', slotNo: 2 }, { id: 'user:dark:2', baseTheme: 'dark', slotNo: 2 },
{ id: 'user:dark:3', label: 'ダーク 3', baseTheme: 'dark', slotNo: 3 }, { id: 'user:dark:3', baseTheme: 'dark', slotNo: 3 }]
]
const themeTokenGroups: ThemeTokenGroupSpec[] = [ const themeTokenGroups: ThemeTokenGroupSpec[] = [
{ title: '基本', { title: 'theme.basic',
fields: [ fields: [
{ key: 'background', label: '背景' }, { key: 'background', label: 'theme.tokens.background' },
{ key: 'foreground', label: '文字' }, { key: 'foreground', label: 'theme.tokens.foreground' },
{ key: 'link', label: 'リンク' }] }] { key: 'link', label: 'theme.tokens.link' }] }]
const ThemeSegmentedControl = <T extends string | number,> ( const ThemeSegmentedControl = <T extends string | number,> (
@@ -220,12 +220,12 @@ const previewTag = (
children: [] }) children: [] })
const previewTags: Record<Category, Tag> = { const previewTags: Record<Category, Tag> = {
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }), deerjikist: previewTag (1, '', 'deerjikist', 12, true, { hasDeerjikists: true }),
meme: previewTag (2, 'ミーム', 'meme', 34, true), meme: previewTag (2, '', 'meme', 34, true),
character: previewTag (3, 'キャラクター', 'character', 56, false), character: previewTag (3, '', 'character', 56, false),
general: previewTag (4, '一般', 'general', 78, true), general: previewTag (4, '', 'general', 78, true),
material: previewTag (5, '素材', 'material', 90, false), material: previewTag (5, '', 'material', 90, false),
meta: previewTag (6, 'メタ', 'meta', 21, true), meta: previewTag (6, '', 'meta', 21, true),
nico: previewTag (7, 'nico:sm9', 'nico', 43, false) } nico: previewTag (7, 'nico:sm9', 'nico', 43, false) }
const parseTab = (search: string): SettingsTab => { const parseTab = (search: string): SettingsTab => {
@@ -244,8 +244,16 @@ const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
[`--tag-colour-${ category }`, tagColours[category]], [`--tag-colour-${ category }`, tagColours[category]],
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties [`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
const themeLabelBySelection = (selection: UserThemeSlotSelection): string => const themeLabelBySelection = (
themeSelections.find (item => item.id === selection)?.label ?? 'ライト 1' 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 = ( const ensureCompleteThemeSlots = (
themeSlots: UserThemeSlotMap, themeSlots: UserThemeSlotMap,
@@ -417,21 +425,16 @@ const hexToThemeToken = (
isTopNavThemeTokenKey (key) ? value : hexToHslToken (value) isTopNavThemeTokenKey (key) ? value : hexToHslToken (value)
const AccountSection: FC<AccountSectionProps> = ( const AccountSection: FC<AccountSectionProps> = props => {
{ name, const { name, onInheritOpen, onNameChange, onUserCodeOpen,
onInheritOpen, onUserSubmit, user, userBaseErrors, userFieldErrors } = props
onNameChange, const { message, label } = useUITranslation ('settings')
onUserCodeOpen, return (
onUserSubmit,
user,
userBaseErrors,
userFieldErrors },
) => (
<section className={sectionClassName}> <section className={sectionClassName}>
<h2 className="text-xl font-bold">アカウント</h2> <h2 className="text-xl font-bold">{label ('tabs.account')}</h2>
<FieldError messages={userBaseErrors}/> <FieldError messages={userBaseErrors}/>
<FormField label="表示名" messages={userFieldErrors.name}> <FormField label={label ('account.displayName')} messages={userFieldErrors.name}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<input <input
@@ -440,68 +443,61 @@ const AccountSection: FC<AccountSectionProps> = (
aria-invalid={invalid} aria-invalid={invalid}
className={inputClass (invalid)} className={inputClass (invalid)}
value={name} value={name}
placeholder="名もなきニジラー" placeholder={message ('shared:labels.anonymous').text}
lang={message ('shared:labels.anonymous').language}
onChange={ev => onNameChange (ev.target.value)}/> onChange={ev => onNameChange (ev.target.value)}/>
{!(user.name) && ( {!(user.name) && (
<p className="mt-1 text-sm text-red-500"> <p className="mt-1 text-sm text-red-500">
名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!! {label ('account.unnamedWarning')}
</p>)} </p>)}
</>)} </>)}
</FormField> </FormField>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button type="button" onClick={onUserSubmit}> <Button type="button" onClick={onUserSubmit}>
表示名を更新 {label ('account.updateName')}
</Button> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>引継ぎ</Label> <Label>{label ('account.transfer')}</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
type="button" type="button"
onClick={onUserCodeOpen} onClick={onUserCodeOpen}
className="bg-gray-600 text-white" className="bg-gray-600 text-white"
disabled={!(user)}> disabled={!(user)}>
引継ぎコードを表示 {label ('account.showCode')}
</Button> </Button>
<Button <Button
type="button" type="button"
onClick={onInheritOpen} onClick={onInheritOpen}
className="bg-red-600 text-white" className="bg-red-600 text-white"
disabled={!(user)}> disabled={!(user)}>
ほかのブラウザから引継ぐ {label ('account.inherit')}
</Button> </Button>
</div> </div>
</div> </div>
</section>) </section>)
}
const ThemeSection: FC<ThemeSectionProps> = (
{ draftThemeMode, const ThemeSection: FC<ThemeSectionProps> = props => {
draftActiveLightThemeSlotNo, const { draftThemeMode, draftActiveLightThemeSlotNo, draftActiveDarkThemeSlotNo,
draftActiveDarkThemeSlotNo, currentDisplayedThemeSlot, selectedTheme, hasUnsavedChanges,
currentDisplayedThemeSlot, settingsBaseErrors, settingsFieldErrors, onSelectThemeMode,
selectedTheme, onSelectLightThemeSlotNo, onSelectDarkThemeSlotNo, onThemeTokenChange,
hasUnsavedChanges, onTagColourChange, onResetThemeSlot, onThemeSubmit, onThemeDiscard } = props
settingsBaseErrors, const { message, label } = useUITranslation ('settings')
settingsFieldErrors, const locale = useLocale ()
onSelectThemeMode,
onSelectLightThemeSlotNo,
onSelectDarkThemeSlotNo,
onThemeTokenChange,
onTagColourChange,
onResetThemeSlot,
onThemeSubmit,
onThemeDiscard },
) => {
return ( return (
<section className={sectionClassName}> <section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-bold">テーマ</h2> <h2 className="text-xl font-bold">{label ('tabs.theme')}</h2>
{hasUnsavedChanges && ( {hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300"> <p className="text-sm text-amber-700 dark:text-amber-300">
未保存の変更があります {label ('unsaved.title')}
</p>)} </p>)}
</div> </div>
@@ -510,7 +506,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
type="button" type="button"
variant="outline" variant="outline"
onClick={onResetThemeSlot}> onClick={onResetThemeSlot}>
既定に戻す {label ('actions.reset')}
</Button> </Button>
<Button <Button
@@ -518,13 +514,14 @@ const ThemeSection: FC<ThemeSectionProps> = (
variant="destructive" variant="destructive"
disabled={!(hasUnsavedChanges)} disabled={!(hasUnsavedChanges)}
onClick={onThemeDiscard}> onClick={onThemeDiscard}>
変更を破棄 {label ('actions.discard')}
</Button> </Button>
<Button <Button
type="button" type="button"
disabled={!(hasUnsavedChanges)} disabled={!(hasUnsavedChanges)}
onClick={onThemeSubmit}> onClick={onThemeSubmit}>
{`${ themeLabelBySelection (currentDisplayedThemeSlot) } に保存`} {label ('theme.saveToSlot', {
slot: themeLabelBySelection (currentDisplayedThemeSlot, locale) })}
</Button> </Button>
</div> </div>
</div> </div>
@@ -535,42 +532,48 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div className="space-y-3 rounded-xl p-4"> <div className="space-y-3 rounded-xl p-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="shrink-0"> <div className="shrink-0">
<Label>表示モード</Label> <Label>{label ('theme.displayMode')}</Label>
</div> </div>
<ThemeSegmentedControl <ThemeSegmentedControl
value={draftThemeMode} value={draftThemeMode}
options={themeModeSelections.map (selection => ({ options={themeModeSelections.map (selection => ({
value: selection.id, value: selection.id,
label: selection.label }))} label: label (selection.label) }))}
onChange={value => onSelectThemeMode (value as ClientThemeMode)}/> onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>ライト時</Label> <Label>{label ('theme.whenLight')}</Label>
<select <select
className={cn (inputClass (false), 'w-full')} className={cn (inputClass (false), 'w-full')}
value={draftActiveLightThemeSlotNo} value={draftActiveLightThemeSlotNo}
aria-label={message ('theme.whenLight').text}
lang={message ('theme.whenLight').language}
onChange={ev => onChange={ev =>
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}> onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSlotNoSelections.map (selection => ( {themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}> <option key={selection.value} value={selection.value}>
{`ライト ${ selection.value }`} {message ('theme.slot', { mode: message ('theme.modes.light').text,
number: selection.value }).text}
</option>))} </option>))}
</select> </select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>ダーク時</Label> <Label>{label ('theme.whenDark')}</Label>
<select <select
className={cn (inputClass (false), 'w-full')} className={cn (inputClass (false), 'w-full')}
value={draftActiveDarkThemeSlotNo} value={draftActiveDarkThemeSlotNo}
aria-label={message ('theme.whenDark').text}
lang={message ('theme.whenDark').language}
onChange={ev => onChange={ev =>
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}> onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSlotNoSelections.map (selection => ( {themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}> <option key={selection.value} value={selection.value}>
{`ダーク ${ selection.value }`} {message ('theme.slot', { mode: message ('theme.modes.dark').text,
number: selection.value }).text}
</option>))} </option>))}
</select> </select>
</div> </div>
@@ -583,17 +586,19 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div <div
key={group.title} key={group.title}
className="space-y-3 rounded-xl p-4"> className="space-y-3 rounded-xl p-4">
<h3 className="text-sm font-semibold">{group.title}</h3> <h3 className="text-sm font-semibold">{label (group.title)}</h3>
<div className="space-y-1"> <div className="space-y-1">
{group.fields.map (field => ( {group.fields.map (field => (
<div <div
key={field.key} key={field.key}
className="flex items-center justify-between gap-4 border-b border-border/60 py-2 className="flex items-center justify-between gap-4 border-b border-border/60 py-2
last:border-b-0"> last:border-b-0">
<p className="text-sm font-medium">{field.label}</p> <p className="text-sm font-medium">{label (field.label)}</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="color" type="color"
aria-label={message (field.label).text}
lang={message (field.label).language}
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])} value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
onChange={ev => onChange={ev =>
onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))} onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
@@ -608,7 +613,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div className="space-y-3 rounded-xl bg-background/70 p-4"> <div className="space-y-3 rounded-xl bg-background/70 p-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<h3 className="font-medium">タグ</h3> <h3 className="font-medium">{label ('navigation:tags')}</h3>
</div> </div>
</div> </div>
@@ -619,10 +624,14 @@ const ThemeSection: FC<ThemeSectionProps> = (
className="flex items-center justify-between gap-4 border-b border-border/60 py-2 className="flex items-center justify-between gap-4 border-b border-border/60 py-2
last:border-b-0"> last:border-b-0">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p> <p className="text-sm font-medium">
{label (`common:categories.${category}`)}
</p>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}> <div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink <TagLink
tag={previewTags[category]} lang={message (`theme.previews.${category}`).language}
tag={{ ...previewTags[category],
name: message (`theme.previews.${category}`).text }}
linkFlg={false} linkFlg={false}
truncateOnMobile truncateOnMobile
withWiki={false}/> withWiki={false}/>
@@ -632,6 +641,8 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="color" type="color"
aria-label={message (`common:categories.${category}`).text}
lang={message (`common:categories.${category}`).language}
value={selectedTheme.tokens.tagColours[category]} value={selectedTheme.tokens.tagColours[category]}
onChange={ev => onTagColourChange (category, ev.target.value)} onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-9 w-14 cursor-pointer rounded border border-border className="h-9 w-14 cursor-pointer rounded border border-border
@@ -647,6 +658,8 @@ const ThemeSection: FC<ThemeSectionProps> = (
const SettingPage: FC<Props> = ({ user, setUser }) => { const SettingPage: FC<Props> = ({ user, setUser }) => {
const { message, label } = useUITranslation ('settings')
const locale = useLocale ()
const location = useLocation () const location = useLocation ()
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
const dialogue = useDialogue () const dialogue = useDialogue ()
@@ -708,12 +721,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo) ?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo)
return { return {
id: currentSelection.baseTheme, id: currentSelection.baseTheme,
name: themeLabelBySelection (currentSelection.id), name: themeLabelBySelection (currentSelection.id, locale),
builtin: false, builtin: false,
baseTheme: currentSelection.baseTheme, baseTheme: currentSelection.baseTheme,
tokens: themeSlot.tokens } tokens: themeSlot.tokens }
}, },
[displayedThemeSelection.selection, draftThemeSlots]) [displayedThemeSelection.selection, draftThemeSlots, locale])
const hasUnsavedThemeChanges = useMemo ( const hasUnsavedThemeChanges = useMemo (
() => () =>
serialiseThemeTokens (draftThemeSlots, displayedThemeSelection.selection) serialiseThemeTokens (draftThemeSlots, displayedThemeSelection.selection)
@@ -753,12 +766,13 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
} }
const confirmDiscardChanges = async ( const confirmDiscardChanges = async (
description: string, description: ReactNode,
cancelText: string, cancelText: ReactNode,
confirmText: string, confirmText: ReactNode,
): Promise<boolean> => ): Promise<boolean> =>
dialogue.confirm ({ dialogue.confirm ({
title: '未保存の変更があります', title: label ('unsaved.title'),
closeLabel: label ('actions.close'),
description, description,
confirmText, confirmText,
cancelText, cancelText,
@@ -866,9 +880,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
} }
const confirmed = await confirmDiscardChanges ( const confirmed = await confirmDiscardChanges (
'このタブを離れると、保存していない変更は失われます。', label ('unsaved.leaveTab'),
'このタブに残る', label ('unsaved.stayTab'),
'変更を破棄して移動') label ('unsaved.discardAndMove'))
if (!(confirmed)) if (!(confirmed))
return return
@@ -878,9 +892,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const requestThemeStateChange = async ( const requestThemeStateChange = async (
buildNextState: (baseState: ThemeDraftState) => ThemeDraftState, buildNextState: (baseState: ThemeDraftState) => ThemeDraftState,
description = 'このまま切り替えると、保存していない変更は失われます。', description = label ('unsaved.switchTheme'),
cancelText = 'このまま編集する', cancelText = label ('unsaved.keepEditing'),
confirmText = '変更を破棄して切り替える', confirmText = label ('unsaved.discardAndSwitch'),
): Promise<void> => { ): Promise<void> => {
const baseState = currentThemeDraftState () const baseState = currentThemeDraftState ()
let themeSlots = baseState.themeSlots let themeSlots = baseState.themeSlots
@@ -1033,12 +1047,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{ headers: { 'Content-Type': 'multipart/form-data' } }) { headers: { 'Content-Type': 'multipart/form-data' } })
setUser (currentUser => ({ ...currentUser, ...data })) setUser (currentUser => ({ ...currentUser, ...data }))
setName (data.name ?? '') setName (data.name ?? '')
toast ({ title: '表示名を更新しました.' }) toast ({ title: label ('account.nameSaved') })
} }
catch (submitError) catch (submitError)
{ {
applyUserValidationError (submitError) applyUserValidationError (submitError)
toast ({ title: '表示名を更新できませんでした.' }) toast ({ title: label ('account.nameFailed') })
} }
} }
@@ -1069,12 +1083,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setDraftThemeSlots (completeThemeSlots) setDraftThemeSlots (completeThemeSlots)
setCachedUserThemeSlots (completeThemeSlots) setCachedUserThemeSlots (completeThemeSlots)
applyClientAppearance (completeThemeSlots) applyClientAppearance (completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' }) toast ({ title: label ('theme.saved') })
} }
catch (submitError) catch (submitError)
{ {
applySettingsValidationError (submitError) applySettingsValidationError (submitError)
toast ({ title: 'テーマ設定を保存できませんでした.' }) toast ({ title: label ('theme.failed') })
} }
} }
@@ -1133,7 +1147,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
if (cancelled) if (cancelled)
return return
setSettingsError ('設定の読込みに失敗しました.') setSettingsError ('loadFailed')
} }
finally finally
{ {
@@ -1223,8 +1237,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
draftActiveDarkThemeSlotNo, draftActiveDarkThemeSlotNo,
currentDisplayedThemeSlot: displayedThemeSelection.selection, currentDisplayedThemeSlot: displayedThemeSelection.selection,
selectedTheme, selectedTheme,
displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection), displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection, locale),
systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト', systemThemeLabel: message (`theme.modes.${displayedThemeSelection.baseTheme}`).text,
hasUnsavedChanges: hasUnsavedThemeChanges, hasUnsavedChanges: hasUnsavedThemeChanges,
settingsBaseErrors, settingsBaseErrors,
settingsFieldErrors, settingsFieldErrors,
@@ -1262,15 +1276,16 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<MainArea> <MainArea>
<Helmet> <Helmet>
<meta name="robots" content="noindex"/> <meta name="robots" content="noindex"/>
<title>設定 | {SITE_TITLE}</title> <title lang={message ('title').language}>{message ('title').text} | {SITE_TITLE}</title>
</Helmet> </Helmet>
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden"> <div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
<PageTitle>設定</PageTitle> <PageTitle>{label ('title')}</PageTitle>
<FieldError messages={settingsError ? [settingsError] : []}/> <FieldError messages={settingsError ? [label (settingsError)] : []}/>
<div <div
role="tablist" role="tablist"
aria-label="設定区分" aria-label={message ('tabList').text}
lang={message ('tabList').language}
aria-orientation="horizontal" aria-orientation="horizontal"
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1"> className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1">
{tabs.map (tab => ( {tabs.map (tab => (
@@ -1291,11 +1306,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
onClick={() => { onClick={() => {
requestActiveTab (tab.id) requestActiveTab (tab.id)
}}> }}>
{tab.label} {label (tab.label)}
{settingsTabErrors[tab.id] ? ' !' : ''} {settingsTabErrors[tab.id] ? ' !' : ''}
</button>))} </button>))}
</div> </div>
{loading ? 'Loading...' : ( {loading ? label ('common:loading') : (
<div <div
id={tabPanelId (activeTab)} id={tabPanelId (activeTab)}
role="tabpanel" role="tabpanel"
@@ -1322,7 +1337,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<div className="grid w-full max-w-[58rem] grid-cols-[12rem_minmax(0,1fr)] gap-6"> <div className="grid w-full max-w-[58rem] grid-cols-[12rem_minmax(0,1fr)] gap-6">
<div <div
role="tablist" role="tablist"
aria-label="設定区分" aria-label={message ('tabList').text}
lang={message ('tabList').language}
aria-orientation="vertical" aria-orientation="vertical"
className="sticky top-4 flex h-fit flex-col gap-2"> className="sticky top-4 flex h-fit flex-col gap-2">
{tabs.map (tab => ( {tabs.map (tab => (
@@ -1346,15 +1362,15 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
requestActiveTab (tab.id) requestActiveTab (tab.id)
}} }}
onKeyDown={handleTabKeyDown}> onKeyDown={handleTabKeyDown}>
{tab.label} {label (tab.label)}
{settingsTabErrors[tab.id] ? ' ⚠' : ''} {settingsTabErrors[tab.id] ? ' ⚠' : ''}
</button>))} </button>))}
</div> </div>
<div className="w-full max-w-xl justify-self-center space-y-4"> <div className="w-full max-w-xl justify-self-center space-y-4">
<PageTitle>設定</PageTitle> <PageTitle>{label ('title')}</PageTitle>
<FieldError messages={settingsError ? [settingsError] : []}/> <FieldError messages={settingsError ? [label (settingsError)] : []}/>
{loading ? 'Loading...' : ( {loading ? label ('common:loading') : (
<div <div
id={tabPanelId (activeTab)} id={tabPanelId (activeTab)}
role="tabpanel" role="tabpanel"
+14 -14
ファイルの表示
@@ -1,6 +1,4 @@
import type { FC } from 'react' import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
@@ -10,26 +8,25 @@ import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api' import { wikiKeys } from '@/lib/queryKeys'
import { dateString } from '@/lib/utils' import { localisedDateString } from '@/lib/utils'
import { fetchWikiChanges } from '@/lib/wiki'
import type { WikiPageChange } from '@/types' import type { FC } from 'react'
const WikiHistoryPage: FC = () => { const WikiHistoryPage: FC = () => {
const { message, label } = useUITranslation ('wiki') const { message, label } = useUITranslation ('wiki')
const locale = useLocale () const locale = useLocale ()
const [changes, setChanges] = useState<WikiPageChange[]> ([])
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const id = query.get ('id') const id = query.get ('id')
useEffect (() => { const params = id ? { id } : { }
void (async () => { const { data: changes = [], isLoading, isError } = useQuery ({
setChanges (await apiGet<WikiPageChange[]> ('/wiki/changes', { locale, params: id ? { id } : { } })) queryKey: wikiKeys.changes (params, locale),
}) () queryFn: () => fetchWikiChanges (params, locale) })
}, [id, location.search, locale])
return ( return (
<MainArea> <MainArea>
@@ -38,6 +35,8 @@ const WikiHistoryPage: FC = () => {
</Helmet> </Helmet>
<PageTitle>{label ('historyHeading')}</PageTitle> <PageTitle>{label ('historyHeading')}</PageTitle>
{isLoading && <p>{label ('common:loading')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
<table className="table-auto w-full border-collapse"> <table className="table-auto w-full border-collapse">
<thead className="border-b-2 border-black dark:border-white"> <thead className="border-b-2 border-black dark:border-white">
@@ -66,14 +65,15 @@ const WikiHistoryPage: FC = () => {
{change.wikiPage.deprecatedAt != null && label ('shared:labels.deprecatedSuffix')} {change.wikiPage.deprecatedAt != null && label ('shared:labels.deprecatedSuffix')}
</td> </td>
<td className="p-2"> <td className="p-2">
{change.pred == null ? label ('navigation:create') : label ('shared:actions.update')} {change.pred == null
? label ('navigation:create') : label ('shared:actions.update')}
</td> </td>
<td className="p-2"> <td className="p-2">
<PrefetchLink to={`/users/${ change.user.id }`}> <PrefetchLink to={`/users/${ change.user.id }`}>
{change.user.name} {change.user.name}
</PrefetchLink> </PrefetchLink>
<br/> <br/>
{dateString (change.timestamp)} {localisedDateString (change.timestamp, locale)}
</td> </td>
</tr>))} </tr>))}
</tbody> </tbody>
+4 -1
ファイルの表示
@@ -325,7 +325,10 @@ export type PostVersion = {
thumbnailBase: { current: string | null; prev: string | null } thumbnailBase: { current: string | null; prev: string | null }
videoMs: { current: number | null; prev: number | null } videoMs: { current: number | null; prev: number | null }
tags: { name: string tags: { name: string
restoreName: string | null
external: boolean
type: 'context' | 'added' | 'removed' }[] type: 'context' | 'added' | 'removed' }[]
parentPostIds: string
parentPosts: { id: number parentPosts: { id: number
title: string title: string
type: 'context' | 'added' | 'removed' }[] type: 'context' | 'added' | 'removed' }[]
@@ -366,7 +369,7 @@ export type TagVersion = {
tagId: number tagId: number
versionNo: number versionNo: number
eventType: 'create' | 'update' | 'discard' | 'restore' eventType: 'create' | 'update' | 'discard' | 'restore'
name: { current: string; prev: string | null } name: { current: string | null; prev: string | null }
category: { current: Category; prev: Category | null } category: { current: Category; prev: Category | null }
deprecatedAt: { current: string | null; prev: string | null } deprecatedAt: { current: string | null; prev: string | null }
aliases: { name: string; type: 'context' | 'added' | 'removed' }[] aliases: { name: string; type: 'context' | 'added' | 'removed' }[]