このコミットが含まれているのは:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
{
|
||||||
return
|
setHL (current => current.visible
|
||||||
}
|
? { left: 0, width: 0, visible: false } : current)
|
||||||
|
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) === '//'
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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": "年月日不詳"
|
||||||
|
}
|
||||||
@@ -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": "設定: キーボード"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"resultsCount": "{{count}} ペ",
|
"resultsCount": "{{count}} ペ",
|
||||||
"menu": "メニュー",
|
"menu": "メニュー",
|
||||||
"anonymous": "レサㇰ ユㇰ ニチカ ウタㇻ",
|
"anonymous": "レサㇰ ユㇰ ニチカ ウタㇻ",
|
||||||
"bot": "bot カㇻ",
|
"bot": "Bot カㇻ",
|
||||||
"deprecatedSuffix": "(エイワンケ ソモ エアㇱカイ)",
|
"deprecatedSuffix": "(エイワンケ ソモ エアㇱカイ)",
|
||||||
"development": "カㇻ コㇿ アン シㇼキ" },
|
"development": "カㇻ コㇿ アン シㇼキ" },
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -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": "年月日不詳"
|
||||||
|
}
|
||||||
@@ -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": "設定: キーボード"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
|||||||
@@ -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>))}
|
||||||
|
|||||||
@@ -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,91 +425,79 @@ 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,
|
<section className={sectionClassName}>
|
||||||
user,
|
<h2 className="text-xl font-bold">{label ('tabs.account')}</h2>
|
||||||
userBaseErrors,
|
<FieldError messages={userBaseErrors}/>
|
||||||
userFieldErrors },
|
|
||||||
) => (
|
|
||||||
<section className={sectionClassName}>
|
|
||||||
<h2 className="text-xl font-bold">アカウント</h2>
|
|
||||||
<FieldError messages={userBaseErrors}/>
|
|
||||||
|
|
||||||
<FormField label="表示名" messages={userFieldErrors.name}>
|
<FormField label={label ('account.displayName')} messages={userFieldErrors.name}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
className={inputClass (invalid)}
|
className={inputClass (invalid)}
|
||||||
value={name}
|
value={name}
|
||||||
placeholder="名もなきニジラー"
|
placeholder={message ('shared:labels.anonymous').text}
|
||||||
onChange={ev => onNameChange (ev.target.value)}/>
|
lang={message ('shared:labels.anonymous').language}
|
||||||
{!(user.name) && (
|
onChange={ev => onNameChange (ev.target.value)}/>
|
||||||
<p className="mt-1 text-sm text-red-500">
|
{!(user.name) && (
|
||||||
名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!!
|
<p className="mt-1 text-sm text-red-500">
|
||||||
</p>)}
|
{label ('account.unnamedWarning')}
|
||||||
</>)}
|
</p>)}
|
||||||
</FormField>
|
</>)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button type="button" onClick={onUserSubmit}>
|
|
||||||
表示名を更新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>引継ぎ</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button type="button" onClick={onUserSubmit}>
|
||||||
type="button"
|
{label ('account.updateName')}
|
||||||
onClick={onUserCodeOpen}
|
|
||||||
className="bg-gray-600 text-white"
|
|
||||||
disabled={!(user)}>
|
|
||||||
引継ぎコードを表示
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={onInheritOpen}
|
|
||||||
className="bg-red-600 text-white"
|
|
||||||
disabled={!(user)}>
|
|
||||||
ほかのブラウザから引継ぐ
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>)
|
|
||||||
|
|
||||||
const ThemeSection: FC<ThemeSectionProps> = (
|
<div className="space-y-2">
|
||||||
{ draftThemeMode,
|
<Label>{label ('account.transfer')}</Label>
|
||||||
draftActiveLightThemeSlotNo,
|
<div className="flex flex-wrap gap-2">
|
||||||
draftActiveDarkThemeSlotNo,
|
<Button
|
||||||
currentDisplayedThemeSlot,
|
type="button"
|
||||||
selectedTheme,
|
onClick={onUserCodeOpen}
|
||||||
hasUnsavedChanges,
|
className="bg-gray-600 text-white"
|
||||||
settingsBaseErrors,
|
disabled={!(user)}>
|
||||||
settingsFieldErrors,
|
{label ('account.showCode')}
|
||||||
onSelectThemeMode,
|
</Button>
|
||||||
onSelectLightThemeSlotNo,
|
<Button
|
||||||
onSelectDarkThemeSlotNo,
|
type="button"
|
||||||
onThemeTokenChange,
|
onClick={onInheritOpen}
|
||||||
onTagColourChange,
|
className="bg-red-600 text-white"
|
||||||
onResetThemeSlot,
|
disabled={!(user)}>
|
||||||
onThemeSubmit,
|
{label ('account.inherit')}
|
||||||
onThemeDiscard },
|
</Button>
|
||||||
) => {
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ThemeSection: FC<ThemeSectionProps> = props => {
|
||||||
|
const { draftThemeMode, draftActiveLightThemeSlotNo, draftActiveDarkThemeSlotNo,
|
||||||
|
currentDisplayedThemeSlot, selectedTheme, hasUnsavedChanges,
|
||||||
|
settingsBaseErrors, settingsFieldErrors, onSelectThemeMode,
|
||||||
|
onSelectLightThemeSlotNo, onSelectDarkThemeSlotNo, onThemeTokenChange,
|
||||||
|
onTagColourChange, onResetThemeSlot, onThemeSubmit, onThemeDiscard } = props
|
||||||
|
const { message, label } = useUITranslation ('settings')
|
||||||
|
const locale = useLocale ()
|
||||||
return (
|
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"
|
||||||
|
|||||||
@@ -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' }[]
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする