Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -1,23 +1,25 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useBeforeUnload, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import Label from '@/components/common/Label'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import TagLink from '@/components/TagLink'
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection'
|
||||
import InheritDialogue from '@/components/users/InheritDialogue'
|
||||
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
|
||||
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
|
||||
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { CATEGORIES } from '@/consts'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import { apiPut } from '@/lib/api'
|
||||
import { i18n } from '@/lib/i18n'
|
||||
import {
|
||||
applyClientAppearance,
|
||||
applyThemeAppearanceState,
|
||||
@@ -40,8 +42,11 @@ import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
||||
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||
import { cn, inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { useHydrated } from '@/lib/useHydrated'
|
||||
import { sessionKeys } from '@/lib/queryKeys'
|
||||
|
||||
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 {
|
||||
@@ -84,7 +89,6 @@ type ThemeModeItem = {
|
||||
|
||||
type ThemeSelectionItem = {
|
||||
id: UserThemeSlotSelection
|
||||
label: string
|
||||
baseTheme: BuiltinThemeId
|
||||
slotNo: UserThemeSlotNo }
|
||||
|
||||
@@ -128,7 +132,7 @@ type ThemeDraftState = {
|
||||
|
||||
type ThemeSegmentedOption<T extends string | number> = {
|
||||
value: T
|
||||
label: string }
|
||||
label: ReactNode }
|
||||
|
||||
type ThemeSegmentedControlProps<T extends string | number> = {
|
||||
value: T
|
||||
@@ -136,15 +140,15 @@ type ThemeSegmentedControlProps<T extends string | number> = {
|
||||
onChange: (value: T) => void }
|
||||
|
||||
const tabs: TabSpec[] = [
|
||||
{ id: 'account', label: 'アカウント' },
|
||||
{ id: 'behavior', label: '動作' },
|
||||
{ id: 'theme', label: 'テーマ' },
|
||||
{ id: 'keyboard', label: 'キーボード' } ]
|
||||
{ id: 'account', label: 'tabs.account' },
|
||||
{ id: 'behavior', label: 'tabs.behavior' },
|
||||
{ id: 'theme', label: 'tabs.theme' },
|
||||
{ id: 'keyboard', label: 'tabs.keyboard' }]
|
||||
|
||||
const themeModeSelections: ThemeModeItem[] = [
|
||||
{ id: 'system', label: 'システム設定に従う' },
|
||||
{ id: 'light', label: 'ライト' },
|
||||
{ id: 'dark', label: 'ダーク' } ]
|
||||
{ id: 'system', label: 'theme.modes.system' },
|
||||
{ id: 'light', label: 'theme.modes.light' },
|
||||
{ id: 'dark', label: 'theme.modes.dark' }]
|
||||
|
||||
const themeSlotNoSelections: ThemeSegmentedOption<UserThemeSlotNo>[] = [
|
||||
{ value: 1, label: '1' },
|
||||
@@ -155,20 +159,19 @@ const sectionClassName =
|
||||
'space-y-4 rounded-xl p-4'
|
||||
|
||||
const themeSelections: ThemeSelectionItem[] = [
|
||||
{ id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 },
|
||||
{ id: 'user:light:2', label: 'ライト 2', baseTheme: 'light', slotNo: 2 },
|
||||
{ id: 'user:light:3', label: 'ライト 3', baseTheme: 'light', slotNo: 3 },
|
||||
{ id: 'user:dark:1', label: 'ダーク 1', baseTheme: 'dark', slotNo: 1 },
|
||||
{ id: 'user:dark:2', label: 'ダーク 2', baseTheme: 'dark', slotNo: 2 },
|
||||
{ id: 'user:dark:3', label: 'ダーク 3', baseTheme: 'dark', slotNo: 3 },
|
||||
]
|
||||
{ id: 'user:light:1', baseTheme: 'light', slotNo: 1 },
|
||||
{ id: 'user:light:2', baseTheme: 'light', slotNo: 2 },
|
||||
{ id: 'user:light:3', baseTheme: 'light', slotNo: 3 },
|
||||
{ id: 'user:dark:1', baseTheme: 'dark', slotNo: 1 },
|
||||
{ id: 'user:dark:2', baseTheme: 'dark', slotNo: 2 },
|
||||
{ id: 'user:dark:3', baseTheme: 'dark', slotNo: 3 }]
|
||||
|
||||
const themeTokenGroups: ThemeTokenGroupSpec[] = [
|
||||
{ title: '基本',
|
||||
{ title: 'theme.basic',
|
||||
fields: [
|
||||
{ key: 'background', label: '背景' },
|
||||
{ key: 'foreground', label: '文字' },
|
||||
{ key: 'link', label: 'リンク' }] }]
|
||||
{ key: 'background', label: 'theme.tokens.background' },
|
||||
{ key: 'foreground', label: 'theme.tokens.foreground' },
|
||||
{ key: 'link', label: 'theme.tokens.link' }] }]
|
||||
|
||||
|
||||
const ThemeSegmentedControl = <T extends string | number,> (
|
||||
@@ -219,12 +222,12 @@ const previewTag = (
|
||||
children: [] })
|
||||
|
||||
const previewTags: Record<Category, Tag> = {
|
||||
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
|
||||
meme: previewTag (2, 'ミーム', 'meme', 34, true),
|
||||
character: previewTag (3, 'キャラクター', 'character', 56, false),
|
||||
general: previewTag (4, '一般', 'general', 78, true),
|
||||
material: previewTag (5, '素材', 'material', 90, false),
|
||||
meta: previewTag (6, 'メタ', 'meta', 21, true),
|
||||
deerjikist: previewTag (1, '', 'deerjikist', 12, true, { hasDeerjikists: true }),
|
||||
meme: previewTag (2, '', 'meme', 34, true),
|
||||
character: previewTag (3, '', 'character', 56, false),
|
||||
general: previewTag (4, '', 'general', 78, true),
|
||||
material: previewTag (5, '', 'material', 90, false),
|
||||
meta: previewTag (6, '', 'meta', 21, true),
|
||||
nico: previewTag (7, 'nico:sm9', 'nico', 43, false) }
|
||||
|
||||
const parseTab = (search: string): SettingsTab => {
|
||||
@@ -243,15 +246,23 @@ const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
|
||||
[`--tag-colour-${ category }`, tagColours[category]],
|
||||
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
|
||||
|
||||
const themeLabelBySelection = (selection: UserThemeSlotSelection): string =>
|
||||
themeSelections.find (item => item.id === selection)?.label ?? 'ライト 1'
|
||||
const themeLabelBySelection = (
|
||||
selection: UserThemeSlotSelection,
|
||||
locale: string,
|
||||
): string => {
|
||||
const item = themeSelections.find (entry => entry.id === selection) ?? themeSelections[0]
|
||||
return i18n.t ('settings:theme.slot', {
|
||||
lng: locale,
|
||||
mode: i18n.t (`settings:theme.modes.${item.baseTheme}`, { lng: locale }),
|
||||
number: item.slotNo })
|
||||
}
|
||||
|
||||
const ensureCompleteThemeSlots = (
|
||||
themeSlots: UserThemeSlotMap,
|
||||
): UserThemeSlotMap =>
|
||||
USER_THEME_SLOT_SELECTIONS.reduce<UserThemeSlotMap> (
|
||||
(complete, selection) => {
|
||||
const [_, baseTheme, slotNo] = selection.split (':')
|
||||
const [, baseTheme, slotNo] = selection.split (':')
|
||||
complete[selection] =
|
||||
themeSlots[selection]
|
||||
?? getDefaultUserThemeSlot (
|
||||
@@ -267,7 +278,7 @@ const comparableThemeTokensForSelection = (
|
||||
themeSlots: UserThemeSlotMap,
|
||||
selection: UserThemeSlotSelection,
|
||||
): ThemeTokens => {
|
||||
const [_, baseTheme, slotNo] = selection.split (':')
|
||||
const [, baseTheme, slotNo] = selection.split (':')
|
||||
const fallback =
|
||||
getDefaultUserThemeSlot (
|
||||
baseTheme as BuiltinThemeId,
|
||||
@@ -416,91 +427,79 @@ const hexToThemeToken = (
|
||||
isTopNavThemeTokenKey (key) ? value : hexToHslToken (value)
|
||||
|
||||
|
||||
const AccountSection: FC<AccountSectionProps> = (
|
||||
{ name,
|
||||
onInheritOpen,
|
||||
onNameChange,
|
||||
onUserCodeOpen,
|
||||
onUserSubmit,
|
||||
user,
|
||||
userBaseErrors,
|
||||
userFieldErrors },
|
||||
) => (
|
||||
<section className={sectionClassName}>
|
||||
<h2 className="text-xl font-bold">アカウント</h2>
|
||||
<FieldError messages={userBaseErrors}/>
|
||||
const AccountSection: FC<AccountSectionProps> = props => {
|
||||
const { name, onInheritOpen, onNameChange, onUserCodeOpen,
|
||||
onUserSubmit, user, userBaseErrors, userFieldErrors } = props
|
||||
const { message, label } = useUITranslation ('settings')
|
||||
return (
|
||||
<section className={sectionClassName}>
|
||||
<h2 className="text-xl font-bold">{label ('tabs.account')}</h2>
|
||||
<FieldError messages={userBaseErrors}/>
|
||||
|
||||
<FormField label="表示名" messages={userFieldErrors.name}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}
|
||||
value={name}
|
||||
placeholder="名もなきニジラー"
|
||||
onChange={ev => onNameChange (ev.target.value)}/>
|
||||
{!(user.name) && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!!
|
||||
</p>)}
|
||||
</>)}
|
||||
</FormField>
|
||||
<FormField label={label ('account.displayName')} messages={userFieldErrors.name}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}
|
||||
value={name}
|
||||
placeholder={message ('shared:labels.anonymous').text}
|
||||
lang={message ('shared:labels.anonymous').language}
|
||||
onChange={ev => onNameChange (ev.target.value)}/>
|
||||
{!(user.name) && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{label ('account.unnamedWarning')}
|
||||
</p>)}
|
||||
</>)}
|
||||
</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">
|
||||
<Button
|
||||
type="button"
|
||||
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 type="button" onClick={onUserSubmit}>
|
||||
{label ('account.updateName')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>)
|
||||
|
||||
const ThemeSection: FC<ThemeSectionProps> = (
|
||||
{ draftThemeMode,
|
||||
draftActiveLightThemeSlotNo,
|
||||
draftActiveDarkThemeSlotNo,
|
||||
currentDisplayedThemeSlot,
|
||||
selectedTheme,
|
||||
hasUnsavedChanges,
|
||||
settingsBaseErrors,
|
||||
settingsFieldErrors,
|
||||
onSelectThemeMode,
|
||||
onSelectLightThemeSlotNo,
|
||||
onSelectDarkThemeSlotNo,
|
||||
onThemeTokenChange,
|
||||
onTagColourChange,
|
||||
onResetThemeSlot,
|
||||
onThemeSubmit,
|
||||
onThemeDiscard },
|
||||
) => {
|
||||
<div className="space-y-2">
|
||||
<Label>{label ('account.transfer')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onUserCodeOpen}
|
||||
className="bg-gray-600 text-white"
|
||||
disabled={!(user)}>
|
||||
{label ('account.showCode')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onInheritOpen}
|
||||
className="bg-red-600 text-white"
|
||||
disabled={!(user)}>
|
||||
{label ('account.inherit')}
|
||||
</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 (
|
||||
<section className={sectionClassName}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-bold">テーマ</h2>
|
||||
<h2 className="text-xl font-bold">{label ('tabs.theme')}</h2>
|
||||
{hasUnsavedChanges && (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
未保存の変更があります
|
||||
{label ('unsaved.title')}
|
||||
</p>)}
|
||||
</div>
|
||||
|
||||
@@ -509,7 +508,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onResetThemeSlot}>
|
||||
既定に戻す
|
||||
{label ('actions.reset')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -517,13 +516,14 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
variant="destructive"
|
||||
disabled={!(hasUnsavedChanges)}
|
||||
onClick={onThemeDiscard}>
|
||||
変更を破棄
|
||||
{label ('actions.discard')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!(hasUnsavedChanges)}
|
||||
onClick={onThemeSubmit}>
|
||||
{`${ themeLabelBySelection (currentDisplayedThemeSlot) } に保存`}
|
||||
{label ('theme.saveToSlot', {
|
||||
slot: themeLabelBySelection (currentDisplayedThemeSlot, locale) })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -534,42 +534,48 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
<div className="space-y-3 rounded-xl p-4">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="shrink-0">
|
||||
<Label>表示モード</Label>
|
||||
<Label>{label ('theme.displayMode')}</Label>
|
||||
</div>
|
||||
<ThemeSegmentedControl
|
||||
value={draftThemeMode}
|
||||
options={themeModeSelections.map (selection => ({
|
||||
value: selection.id,
|
||||
label: selection.label }))}
|
||||
label: label (selection.label) }))}
|
||||
onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>ライト時</Label>
|
||||
<Label>{label ('theme.whenLight')}</Label>
|
||||
<select
|
||||
className={cn (inputClass (false), 'w-full')}
|
||||
value={draftActiveLightThemeSlotNo}
|
||||
aria-label={message ('theme.whenLight').text}
|
||||
lang={message ('theme.whenLight').language}
|
||||
onChange={ev =>
|
||||
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
|
||||
{themeSlotNoSelections.map (selection => (
|
||||
<option key={selection.value} value={selection.value}>
|
||||
{`ライト ${ selection.value }`}
|
||||
{message ('theme.slot', { mode: message ('theme.modes.light').text,
|
||||
number: selection.value }).text}
|
||||
</option>))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>ダーク時</Label>
|
||||
<Label>{label ('theme.whenDark')}</Label>
|
||||
<select
|
||||
className={cn (inputClass (false), 'w-full')}
|
||||
value={draftActiveDarkThemeSlotNo}
|
||||
aria-label={message ('theme.whenDark').text}
|
||||
lang={message ('theme.whenDark').language}
|
||||
onChange={ev =>
|
||||
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
|
||||
{themeSlotNoSelections.map (selection => (
|
||||
<option key={selection.value} value={selection.value}>
|
||||
{`ダーク ${ selection.value }`}
|
||||
{message ('theme.slot', { mode: message ('theme.modes.dark').text,
|
||||
number: selection.value }).text}
|
||||
</option>))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -582,17 +588,19 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
<div
|
||||
key={group.title}
|
||||
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">
|
||||
{group.fields.map (field => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="flex items-center justify-between gap-4 border-b border-border/60 py-2
|
||||
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">
|
||||
<input
|
||||
type="color"
|
||||
aria-label={message (field.label).text}
|
||||
lang={message (field.label).language}
|
||||
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
|
||||
onChange={ev =>
|
||||
onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
|
||||
@@ -607,7 +615,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
<div className="space-y-3 rounded-xl bg-background/70 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-medium">タグ</h3>
|
||||
<h3 className="font-medium">{label ('navigation:tags')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -618,10 +626,14 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
className="flex items-center justify-between gap-4 border-b border-border/60 py-2
|
||||
last:border-b-0">
|
||||
<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)}>
|
||||
<TagLink
|
||||
tag={previewTags[category]}
|
||||
lang={message (`theme.previews.${category}`).language}
|
||||
tag={{ ...previewTags[category],
|
||||
name: message (`theme.previews.${category}`).text }}
|
||||
linkFlg={false}
|
||||
truncateOnMobile
|
||||
withWiki={false}/>
|
||||
@@ -631,6 +643,8 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
aria-label={message (`common:categories.${category}`).text}
|
||||
lang={message (`common:categories.${category}`).language}
|
||||
value={selectedTheme.tokens.tagColours[category]}
|
||||
onChange={ev => onTagColourChange (category, ev.target.value)}
|
||||
className="h-9 w-14 cursor-pointer rounded border border-border
|
||||
@@ -646,31 +660,42 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
|
||||
|
||||
const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
const hydrated = useHydrated ()
|
||||
const { data: initialSettings } = useQuery ({
|
||||
queryKey: sessionKeys.settings (user?.id ?? 0),
|
||||
queryFn: fetchUserSettings, enabled: user != null })
|
||||
const { data: initialThemeSlots } = useQuery ({
|
||||
queryKey: sessionKeys.themeSlots (user?.id ?? 0),
|
||||
queryFn: fetchUserThemeSlots, enabled: user != null })
|
||||
const { message, label } = useUITranslation ('settings')
|
||||
const locale = useLocale ()
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
const dialogue = useDialogue ()
|
||||
const { registerUnsavedChangesSource } = useUnsavedChangesGuard ()
|
||||
|
||||
const [inheritVsbl, setInheritVsbl] = useState (false)
|
||||
const [loading, setLoading] = useState (true)
|
||||
const [name, setName] = useState ('')
|
||||
const [loading, setLoading] = useState (initialSettings == null)
|
||||
const [name, setName] = useState (user?.name ?? '')
|
||||
const [settingsError, setSettingsError] = useState<string | null> (null)
|
||||
const [savedThemeMode, setSavedThemeMode] =
|
||||
useState<ClientThemeMode> (getClientThemeMode ())
|
||||
useState<ClientThemeMode> (initialSettings?.theme ?? 'system')
|
||||
const [draftThemeMode, setDraftThemeMode] =
|
||||
useState<ClientThemeMode> (getClientThemeMode ())
|
||||
useState<ClientThemeMode> (initialSettings?.theme ?? 'system')
|
||||
const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] =
|
||||
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
|
||||
useState<UserThemeSlotNo> (1)
|
||||
const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] =
|
||||
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
|
||||
useState<UserThemeSlotNo> (1)
|
||||
const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] =
|
||||
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
|
||||
useState<UserThemeSlotNo> (1)
|
||||
const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] =
|
||||
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
|
||||
useState<UserThemeSlotNo> (1)
|
||||
const [savedThemeSlots, setSavedThemeSlots] =
|
||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots (
|
||||
normaliseUserThemeSlots (initialThemeSlots ?? [])))
|
||||
const [draftThemeSlots, setDraftThemeSlots] =
|
||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots (
|
||||
normaliseUserThemeSlots (initialThemeSlots ?? [])))
|
||||
const [dirtyStates, setDirtyStates] =
|
||||
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
|
||||
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
||||
@@ -688,10 +713,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
|
||||
const displayedThemeSelection = useMemo (
|
||||
() => resolveDisplayedTheme ({
|
||||
themeMode: draftThemeMode,
|
||||
themeMode: (!hydrated && draftThemeMode === 'system') ? 'light' : draftThemeMode,
|
||||
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots),
|
||||
[draftActiveDarkThemeSlotNo,
|
||||
[hydrated, draftActiveDarkThemeSlotNo,
|
||||
draftActiveLightThemeSlotNo,
|
||||
draftThemeMode,
|
||||
draftThemeSlots])
|
||||
@@ -707,12 +732,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo)
|
||||
return {
|
||||
id: currentSelection.baseTheme,
|
||||
name: themeLabelBySelection (currentSelection.id),
|
||||
name: themeLabelBySelection (currentSelection.id, locale),
|
||||
builtin: false,
|
||||
baseTheme: currentSelection.baseTheme,
|
||||
tokens: themeSlot.tokens }
|
||||
},
|
||||
[displayedThemeSelection.selection, draftThemeSlots])
|
||||
[displayedThemeSelection.selection, draftThemeSlots, locale])
|
||||
const hasUnsavedThemeChanges = useMemo (
|
||||
() =>
|
||||
serialiseThemeTokens (draftThemeSlots, displayedThemeSelection.selection)
|
||||
@@ -752,12 +777,13 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
}
|
||||
|
||||
const confirmDiscardChanges = async (
|
||||
description: string,
|
||||
cancelText: string,
|
||||
confirmText: string,
|
||||
description: ReactNode,
|
||||
cancelText: ReactNode,
|
||||
confirmText: ReactNode,
|
||||
): Promise<boolean> =>
|
||||
dialogue.confirm ({
|
||||
title: '未保存の変更があります',
|
||||
title: label ('unsaved.title'),
|
||||
closeLabel: label ('actions.close'),
|
||||
description,
|
||||
confirmText,
|
||||
cancelText,
|
||||
@@ -811,7 +837,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
|
||||
const discardThemeChanges = () => {
|
||||
const selection = displayedThemeSelection.selection
|
||||
const [_, baseTheme, slotNo] = selection.split (':')
|
||||
const [, baseTheme, slotNo] = selection.split (':')
|
||||
const nextThemeSlots = {
|
||||
...draftThemeSlots,
|
||||
[selection]: savedThemeSlots[selection]
|
||||
@@ -865,9 +891,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
}
|
||||
|
||||
const confirmed = await confirmDiscardChanges (
|
||||
'このタブを離れると、保存していない変更は失われます。',
|
||||
'このタブに残る',
|
||||
'変更を破棄して移動')
|
||||
label ('unsaved.leaveTab'),
|
||||
label ('unsaved.stayTab'),
|
||||
label ('unsaved.discardAndMove'))
|
||||
if (!(confirmed))
|
||||
return
|
||||
|
||||
@@ -877,9 +903,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
|
||||
const requestThemeStateChange = async (
|
||||
buildNextState: (baseState: ThemeDraftState) => ThemeDraftState,
|
||||
description = 'このまま切り替えると、保存していない変更は失われます。',
|
||||
cancelText = 'このまま編集する',
|
||||
confirmText = '変更を破棄して切り替える',
|
||||
description = label ('unsaved.switchTheme'),
|
||||
cancelText = label ('unsaved.keepEditing'),
|
||||
confirmText = label ('unsaved.discardAndSwitch'),
|
||||
): Promise<void> => {
|
||||
const baseState = currentThemeDraftState ()
|
||||
let themeSlots = baseState.themeSlots
|
||||
@@ -894,7 +920,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
return
|
||||
|
||||
const selection = displayedThemeSelection.selection
|
||||
const [_, baseTheme, slotNo] = selection.split (':')
|
||||
const [, baseTheme, slotNo] = selection.split (':')
|
||||
themeSlots = {
|
||||
...baseState.themeSlots,
|
||||
[selection]: savedThemeSlots[selection]
|
||||
@@ -1032,12 +1058,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
setUser (currentUser => ({ ...currentUser, ...data }))
|
||||
setName (data.name ?? '')
|
||||
toast ({ title: '表示名を更新しました.' })
|
||||
toast ({ title: label ('account.nameSaved') })
|
||||
}
|
||||
catch (submitError)
|
||||
{
|
||||
applyUserValidationError (submitError)
|
||||
toast ({ title: '表示名を更新できませんでした.' })
|
||||
toast ({ title: label ('account.nameFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,12 +1094,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
setDraftThemeSlots (completeThemeSlots)
|
||||
setCachedUserThemeSlots (completeThemeSlots)
|
||||
applyClientAppearance (completeThemeSlots)
|
||||
toast ({ title: 'テーマ設定を保存しました.' })
|
||||
toast ({ title: label ('theme.saved') })
|
||||
}
|
||||
catch (submitError)
|
||||
{
|
||||
applySettingsValidationError (submitError)
|
||||
toast ({ title: 'テーマ設定を保存できませんでした.' })
|
||||
toast ({ title: label ('theme.failed') })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1132,7 +1158,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
if (cancelled)
|
||||
return
|
||||
|
||||
setSettingsError ('設定の読込みに失敗しました.')
|
||||
setSettingsError ('loadFailed')
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1222,8 +1248,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
draftActiveDarkThemeSlotNo,
|
||||
currentDisplayedThemeSlot: displayedThemeSelection.selection,
|
||||
selectedTheme,
|
||||
displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection),
|
||||
systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト',
|
||||
displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection, locale),
|
||||
systemThemeLabel: message (`theme.modes.${displayedThemeSelection.baseTheme}`).text,
|
||||
hasUnsavedChanges: hasUnsavedThemeChanges,
|
||||
settingsBaseErrors,
|
||||
settingsFieldErrors,
|
||||
@@ -1259,17 +1285,17 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<PageHead title={message ('title')}>
|
||||
<meta name="robots" content="noindex"/>
|
||||
<title>設定 | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
</PageHead>
|
||||
|
||||
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
|
||||
<PageTitle>設定</PageTitle>
|
||||
<FieldError messages={settingsError ? [settingsError] : []}/>
|
||||
<PageTitle>{label ('title')}</PageTitle>
|
||||
<FieldError messages={settingsError ? [label (settingsError)] : []}/>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="設定区分"
|
||||
aria-label={message ('tabList').text}
|
||||
lang={message ('tabList').language}
|
||||
aria-orientation="horizontal"
|
||||
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1">
|
||||
{tabs.map (tab => (
|
||||
@@ -1290,11 +1316,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
onClick={() => {
|
||||
requestActiveTab (tab.id)
|
||||
}}>
|
||||
{tab.label}
|
||||
{label (tab.label)}
|
||||
{settingsTabErrors[tab.id] ? ' !' : ''}
|
||||
</button>))}
|
||||
</div>
|
||||
{loading ? 'Loading...' : (
|
||||
{loading ? label ('common:loading') : (
|
||||
<div
|
||||
id={tabPanelId (activeTab)}
|
||||
role="tabpanel"
|
||||
@@ -1321,7 +1347,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
<div className="grid w-full max-w-[58rem] grid-cols-[12rem_minmax(0,1fr)] gap-6">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="設定区分"
|
||||
aria-label={message ('tabList').text}
|
||||
lang={message ('tabList').language}
|
||||
aria-orientation="vertical"
|
||||
className="sticky top-4 flex h-fit flex-col gap-2">
|
||||
{tabs.map (tab => (
|
||||
@@ -1345,15 +1372,15 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
requestActiveTab (tab.id)
|
||||
}}
|
||||
onKeyDown={handleTabKeyDown}>
|
||||
{tab.label}
|
||||
{label (tab.label)}
|
||||
{settingsTabErrors[tab.id] ? ' ⚠' : ''}
|
||||
</button>))}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xl justify-self-center space-y-4">
|
||||
<PageTitle>設定</PageTitle>
|
||||
<FieldError messages={settingsError ? [settingsError] : []}/>
|
||||
{loading ? 'Loading...' : (
|
||||
<PageTitle>{label ('title')}</PageTitle>
|
||||
<FieldError messages={settingsError ? [label (settingsError)] : []}/>
|
||||
{loading ? label ('common:loading') : (
|
||||
<div
|
||||
id={tabPanelId (activeTab)}
|
||||
role="tabpanel"
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする