このコミットが含まれているのは:
@@ -19,6 +19,7 @@ import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiPut } from '@/lib/api'
|
||||
import {
|
||||
applyClientAppearance,
|
||||
applyThemeSelection,
|
||||
DEFAULT_USER_SETTINGS,
|
||||
deriveUserThemeModeFromSlotSelection,
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
upsertUserThemeSlot,
|
||||
USER_THEME_SLOT_SELECTIONS,
|
||||
} from '@/lib/settings'
|
||||
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
|
||||
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||
import { cn, inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
|
||||
@@ -62,7 +63,7 @@ type Props = {
|
||||
|
||||
type UserFormField = 'name'
|
||||
type SettingsFormField = 'theme'
|
||||
type SettingsTab = 'account' | 'theme' | 'keyboard' | 'behavior'
|
||||
type SettingsTab = 'account' | 'behavior' | 'theme' | 'keyboard'
|
||||
type ThemeTokenKey = Exclude<keyof ThemeTokens, 'tagColours'>
|
||||
|
||||
type TabSpec = {
|
||||
@@ -112,11 +113,15 @@ type ThemeSectionProps = {
|
||||
onThemeSubmit: () => void
|
||||
onThemeDiscard: () => void }
|
||||
|
||||
type SettingsTabDirtyState = {
|
||||
dirty: boolean
|
||||
discard: () => void | Promise<void> }
|
||||
|
||||
const tabs: TabSpec[] = [
|
||||
{ id: 'account', label: 'アカウント' },
|
||||
{ id: 'behavior', label: '動作' },
|
||||
{ id: 'theme', label: 'テーマ' },
|
||||
{ id: 'keyboard', label: 'キーボード' },
|
||||
{ id: 'behavior', label: '動作' } ]
|
||||
{ id: 'keyboard', label: 'キーボード' } ]
|
||||
|
||||
const sectionClassName =
|
||||
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
|
||||
@@ -138,25 +143,15 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
|
||||
fields: [
|
||||
{ key: 'background', label: '背景' },
|
||||
{ key: 'foreground', label: '文字' },
|
||||
{ key: 'primary', label: '主ボタン' },
|
||||
{ key: 'primaryForeground', label: '主ボタン文字' },
|
||||
{ key: 'primary', label: '主色' },
|
||||
{ key: 'accent', label: 'アクセント' },
|
||||
{ key: 'accentForeground', label: 'アクセント文字' },
|
||||
] },
|
||||
{ title: 'パネル・カード',
|
||||
{ title: 'パネル',
|
||||
fields: [
|
||||
{ key: 'card', label: 'カード背景' },
|
||||
{ key: 'cardForeground', label: 'カード文字' },
|
||||
{ key: 'popover', label: 'ポップアップ背景' },
|
||||
{ key: 'popoverForeground', label: 'ポップアップ文字' },
|
||||
{ key: 'muted', label: '薄い背景' },
|
||||
{ key: 'mutedForeground', label: '薄い文字' },
|
||||
] },
|
||||
{ title: '入力欄・境界線',
|
||||
fields: [
|
||||
{ key: 'border', label: '境界線' },
|
||||
{ key: 'input', label: '入力欄' },
|
||||
{ key: 'ring', label: 'フォーカスリング' },
|
||||
] },
|
||||
{ title: 'リンク',
|
||||
fields: [
|
||||
@@ -164,15 +159,12 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
|
||||
] },
|
||||
{ title: '上部ナビ',
|
||||
fields: [
|
||||
{ key: 'topNavBackground', label: '背景' },
|
||||
{ key: 'topNavForeground', label: '文字' },
|
||||
{ key: 'topNavBorder', label: '境界線' },
|
||||
{ key: 'topNavLink', label: 'リンク' },
|
||||
{ key: 'topNavLinkHover', label: 'リンク hover' },
|
||||
{ key: 'topNavActiveBackground', label: '選択中背景' },
|
||||
{ key: 'topNavActiveForeground', label: '選択中文字' },
|
||||
{ key: 'topNavRootBackgroundMobile', label: '通常背景' },
|
||||
{ key: 'topNavRootBackgroundDesktop', label: 'PC 背景' },
|
||||
{ key: 'topNavHighlightBackground', label: '選択中背景' },
|
||||
{ key: 'topNavSubmenuBackground', label: 'サブメニュー背景' },
|
||||
{ key: 'topNavSubmenuForeground', label: 'サブメニュー文字' },
|
||||
{ key: 'topNavMobileActiveBackground', label: 'スマホ選択中背景' },
|
||||
{ key: 'topNavLink', label: 'リンク' },
|
||||
] },
|
||||
]
|
||||
|
||||
@@ -270,6 +262,10 @@ const themeRowBaseLabel = (
|
||||
: themeLabelBySelection (selection)
|
||||
|
||||
|
||||
const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean =>
|
||||
key.startsWith ('topNav')
|
||||
|
||||
|
||||
const formatHslComponent = (value: number): string =>
|
||||
Number.isInteger (value) ? String (value) : value.toFixed (1).replace (/\.0$/, '')
|
||||
|
||||
@@ -367,6 +363,30 @@ const hexToHslToken = (hex: string): string => {
|
||||
}
|
||||
|
||||
|
||||
const themeTokenToHex = (
|
||||
key: ThemeTokenKey,
|
||||
value: string,
|
||||
): string => {
|
||||
if (isTopNavThemeTokenKey (key))
|
||||
{
|
||||
if (value.startsWith ('#'))
|
||||
return value
|
||||
|
||||
const match = value.match (/^hsl\((.+)\)$/)
|
||||
return match == null ? '#000000' : hslTokenToHex (match[1])
|
||||
}
|
||||
|
||||
return hslTokenToHex (value)
|
||||
}
|
||||
|
||||
|
||||
const hexToThemeToken = (
|
||||
key: ThemeTokenKey,
|
||||
value: string,
|
||||
): string =>
|
||||
isTopNavThemeTokenKey (key) ? value : hexToHslToken (value)
|
||||
|
||||
|
||||
const AccountSection: FC<AccountSectionProps> = (
|
||||
{ name,
|
||||
onInheritOpen,
|
||||
@@ -443,183 +463,190 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
onResetThemeSlot,
|
||||
onThemeSubmit,
|
||||
onThemeDiscard },
|
||||
) => (
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
現在の表示: {selectedThemeLabel}
|
||||
</p>
|
||||
{editingThemeSlot && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
編輯対象: {themeLabelBySelection (editingThemeSlot)}
|
||||
</p>)}
|
||||
{hasUnsavedChanges && (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
未保存の変更があります
|
||||
</p>)}
|
||||
) => {
|
||||
const currentSelection =
|
||||
themeSelections.find (item => item.id === draftActiveThemeSlot)
|
||||
?? themeSelections[0]
|
||||
const currentEditableSelection =
|
||||
editingThemeSlot
|
||||
?? (isUserThemeSlotSelection (draftActiveThemeSlot)
|
||||
? draftActiveThemeSlot
|
||||
: null)
|
||||
const canEditTheme = currentEditableSelection != null
|
||||
|
||||
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>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
現在の表示: {selectedThemeLabel}
|
||||
</p>
|
||||
{editingThemeSlot && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
編輯対象: {themeLabelBySelection (editingThemeSlot)}
|
||||
</p>)}
|
||||
{hasUnsavedChanges && (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
未保存の変更があります
|
||||
</p>)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!(hasUnsavedChanges)}
|
||||
onClick={onThemeDiscard}>
|
||||
変更を破棄
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!(hasUnsavedChanges)}
|
||||
onClick={onThemeSubmit}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!(hasUnsavedChanges)}
|
||||
onClick={onThemeDiscard}>
|
||||
変更を破棄
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!(hasUnsavedChanges)}
|
||||
onClick={onThemeSubmit}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldError messages={settingsBaseErrors}/>
|
||||
<FieldError messages={settingsFieldErrors.theme ?? []}/>
|
||||
|
||||
<div className="space-y-3">
|
||||
{themeSelections.map (selection => {
|
||||
const themeSlot =
|
||||
selection.editable
|
||||
? draftThemeSlots[selection.id as UserThemeSlotSelection]
|
||||
: null
|
||||
const isSelected = draftActiveThemeSlot === selection.id
|
||||
const isEditing = editingThemeSlot === selection.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={selection.id}
|
||||
className={cn (
|
||||
'space-y-3 rounded-xl border border-border/70 bg-background/70 p-4',
|
||||
isSelected && 'ring-1 ring-primary/30',
|
||||
)}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">{selection.label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{themeRowBaseLabel (selection.id)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
onClick={() => onSelectThemeSlot (selection.id)}>
|
||||
選択
|
||||
</Button>
|
||||
{selection.editable && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={isEditing ? 'default' : 'outline'}
|
||||
onClick={() =>
|
||||
onEditThemeSlot (selection.id as UserThemeSlotSelection)}>
|
||||
編集
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!(hasUnsavedChanges) || !(isEditing || isSelected)}
|
||||
onClick={onThemeSubmit}>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
onResetThemeSlot (selection.id as UserThemeSlotSelection)}>
|
||||
既定に戻す
|
||||
</Button>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{themeSlot && (
|
||||
<div style={themePreviewStyle (themeSlot.tokens.tagColours)}>
|
||||
<TagLink
|
||||
tag={previewTags.general}
|
||||
linkFlg={false}
|
||||
truncateOnMobile
|
||||
withWiki={false}/>
|
||||
</div>)}
|
||||
</div>)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{themeTokenGroups.map (group => (
|
||||
<div
|
||||
key={group.title}
|
||||
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
|
||||
<h3 className="text-sm font-semibold">{group.title}</h3>
|
||||
<div className="space-y-3">
|
||||
{group.fields.map (field => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
|
||||
sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{field.label}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={hslTokenToHex (selectedTheme.tokens[field.key])}
|
||||
onPointerDown={onEnsureEditableTheme}
|
||||
onFocus={onEnsureEditableTheme}
|
||||
onChange={ev =>
|
||||
onThemeTokenChange (field.key, hexToHslToken (ev.target.value))}
|
||||
className="h-10 w-16 cursor-pointer rounded border border-border
|
||||
bg-transparent p-1"/>
|
||||
</div>
|
||||
</div>))}
|
||||
</div>
|
||||
</div>))}
|
||||
<FieldError messages={settingsBaseErrors}/>
|
||||
<FieldError messages={settingsFieldErrors.theme ?? []}/>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-medium">タグ</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>保存する枠</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<select
|
||||
className={cn (inputClass (false), 'w-full min-w-0 sm:w-64')}
|
||||
value={draftActiveThemeSlot}
|
||||
onChange={ev =>
|
||||
onSelectThemeSlot (ev.target.value as ThemeSlotSelection)}>
|
||||
{themeSelections.map (selection => (
|
||||
<option key={selection.id} value={selection.id}>
|
||||
{selection.label}
|
||||
</option>))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (isUserThemeSlotSelection (draftActiveThemeSlot))
|
||||
{
|
||||
onEditThemeSlot (draftActiveThemeSlot)
|
||||
return
|
||||
}
|
||||
|
||||
onEnsureEditableTheme ()
|
||||
}}>
|
||||
{canEditTheme ? 'この枠を編輯' : '編輯する枠を選ぶ'}
|
||||
</Button>
|
||||
|
||||
{currentEditableSelection && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onResetThemeSlot (currentEditableSelection)}>
|
||||
既定に戻す
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{CATEGORIES.map (category => (
|
||||
<div
|
||||
key={category}
|
||||
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
|
||||
sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
|
||||
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
|
||||
<TagLink
|
||||
tag={previewTags[category]}
|
||||
linkFlg={false}
|
||||
truncateOnMobile
|
||||
withWiki={false}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">{currentSelection.label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{themeRowBaseLabel (currentSelection.id)}
|
||||
</p>
|
||||
{canEditTheme
|
||||
? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
この枠への変更は保存時に反映されます。
|
||||
</p>)
|
||||
: (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
標準テーマは直接編輯できません。編輯する枠を選んでください。
|
||||
</p>)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={selectedTheme.tokens.tagColours[category]}
|
||||
onPointerDown={onEnsureEditableTheme}
|
||||
onFocus={onEnsureEditableTheme}
|
||||
onChange={ev => onTagColourChange (category, ev.target.value)}
|
||||
className="h-10 w-16 cursor-pointer rounded border border-border
|
||||
bg-transparent p-1"/>
|
||||
</div>
|
||||
</div>))}
|
||||
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
|
||||
<TagLink
|
||||
tag={previewTags.general}
|
||||
linkFlg={false}
|
||||
truncateOnMobile
|
||||
withWiki={false}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>)
|
||||
|
||||
<div className="space-y-4">
|
||||
{themeTokenGroups.map (group => (
|
||||
<div
|
||||
key={group.title}
|
||||
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
|
||||
<h3 className="text-sm font-semibold">{group.title}</h3>
|
||||
<div className="space-y-3">
|
||||
{group.fields.map (field => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
|
||||
sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{field.label}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
|
||||
disabled={!(canEditTheme)}
|
||||
onChange={ev =>
|
||||
onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
|
||||
className="h-10 w-16 cursor-pointer rounded border border-border
|
||||
bg-transparent p-1 disabled:cursor-not-allowed
|
||||
disabled:opacity-50"/>
|
||||
</div>
|
||||
</div>))}
|
||||
</div>
|
||||
</div>))}
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-medium">タグ</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{CATEGORIES.map (category => (
|
||||
<div
|
||||
key={category}
|
||||
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
|
||||
sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
|
||||
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
|
||||
<TagLink
|
||||
tag={previewTags[category]}
|
||||
linkFlg={false}
|
||||
truncateOnMobile
|
||||
withWiki={false}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={selectedTheme.tokens.tagColours[category]}
|
||||
disabled={!(canEditTheme)}
|
||||
onChange={ev => onTagColourChange (category, ev.target.value)}
|
||||
className="h-10 w-16 cursor-pointer rounded border border-border
|
||||
bg-transparent p-1 disabled:cursor-not-allowed
|
||||
disabled:opacity-50"/>
|
||||
</div>
|
||||
</div>))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>)
|
||||
}
|
||||
|
||||
|
||||
const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
@@ -642,6 +669,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
||||
const [editingThemeSlot, setEditingThemeSlot] =
|
||||
useState<UserThemeSlotSelection | null> (null)
|
||||
const [dirtyStates, setDirtyStates] =
|
||||
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
|
||||
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
||||
const { conflictActionIds } = useKeyboardShortcutSettings ()
|
||||
const { baseErrors: userBaseErrors,
|
||||
@@ -676,7 +705,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
[draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots],
|
||||
)
|
||||
|
||||
const setActiveTab = (tab: SettingsTab) => {
|
||||
const applyActiveTab = (tab: SettingsTab) => {
|
||||
const params = new URLSearchParams (location.search)
|
||||
params.set ('tab', tab)
|
||||
navigate (
|
||||
@@ -684,12 +713,43 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
{ replace: true })
|
||||
}
|
||||
|
||||
const updateTabDirtyState = (
|
||||
tab: SettingsTab,
|
||||
state: SettingsTabDirtyState,
|
||||
) => {
|
||||
setDirtyStates (current => {
|
||||
const next = { ...current }
|
||||
|
||||
if (!(state.dirty))
|
||||
{
|
||||
delete next[tab]
|
||||
return next
|
||||
}
|
||||
|
||||
next[tab] = state
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const confirmDiscardChanges = async (
|
||||
description: string,
|
||||
cancelText: string,
|
||||
confirmText: string,
|
||||
): Promise<boolean> =>
|
||||
dialogue.confirm ({
|
||||
title: '未保存の変更があります',
|
||||
description,
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant: 'danger',
|
||||
})
|
||||
|
||||
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
|
||||
() => (
|
||||
{ account: Boolean (userFieldErrors.name?.length),
|
||||
behavior: false,
|
||||
theme: Boolean (settingsFieldErrors.theme?.length),
|
||||
keyboard: conflictActionIds.size > 0,
|
||||
behavior: false }),
|
||||
keyboard: conflictActionIds.size > 0 }),
|
||||
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name],
|
||||
)
|
||||
|
||||
@@ -706,29 +766,65 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
applyThemeSelection (activeThemeSlot, themeSlots)
|
||||
}
|
||||
|
||||
const confirmThemeSlotSwitch = async (
|
||||
onConfirmed: (themeSlots: UserThemeSlotMap) => void,
|
||||
): Promise<void> => {
|
||||
if (!(hasUnsavedThemeChanges))
|
||||
{
|
||||
onConfirmed (draftThemeSlots)
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await dialogue.confirm ({
|
||||
title: '未保存の変更があります',
|
||||
description: 'このまま切り替えると、保存していない変更は失われます。',
|
||||
confirmText: '変更を破棄して切り替える',
|
||||
cancelText: 'このまま編輯する',
|
||||
})
|
||||
|
||||
if (!(confirmed))
|
||||
return
|
||||
|
||||
const discardThemeChanges = () => {
|
||||
setDraftActiveThemeSlot (savedActiveThemeSlot)
|
||||
setDraftThemeSlots (savedThemeSlots)
|
||||
setEditingThemeSlot (null)
|
||||
onConfirmed (savedThemeSlots)
|
||||
setCachedUserThemeSlots (savedThemeSlots)
|
||||
setClientActiveThemeSlot (savedActiveThemeSlot)
|
||||
applyClientAppearance (savedThemeSlots)
|
||||
}
|
||||
|
||||
const requestActiveTab = async (
|
||||
nextTab: SettingsTab,
|
||||
): Promise<void> => {
|
||||
if (nextTab === activeTab)
|
||||
return
|
||||
|
||||
const currentDirtyState = dirtyStates[activeTab]
|
||||
if (!(currentDirtyState?.dirty))
|
||||
{
|
||||
applyActiveTab (nextTab)
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await confirmDiscardChanges (
|
||||
'このタブを離れると、保存していない変更は失われます。',
|
||||
'このタブに残る',
|
||||
'変更を破棄して移動',
|
||||
)
|
||||
if (!(confirmed))
|
||||
return
|
||||
|
||||
await currentDirtyState.discard ()
|
||||
applyActiveTab (nextTab)
|
||||
}
|
||||
|
||||
const requestThemeSlotSelection = async (
|
||||
selection: ThemeSlotSelection,
|
||||
editingSlot: UserThemeSlotSelection | null,
|
||||
): Promise<void> => {
|
||||
if (
|
||||
selection === draftActiveThemeSlot
|
||||
&& editingSlot === editingThemeSlot
|
||||
)
|
||||
return
|
||||
|
||||
if (hasUnsavedThemeChanges)
|
||||
{
|
||||
const confirmed = await confirmDiscardChanges (
|
||||
'このまま切り替えると、保存していない変更は失われます。',
|
||||
'この枠に残る',
|
||||
'変更を破棄して切り替える',
|
||||
)
|
||||
if (!(confirmed))
|
||||
return
|
||||
|
||||
discardThemeChanges ()
|
||||
}
|
||||
|
||||
setEditingThemeSlot (editingSlot)
|
||||
applyDraftThemeSelection (selection, savedThemeSlots)
|
||||
}
|
||||
|
||||
const ensureEditableThemeSlot = async (): Promise<UserThemeSlotSelection | null> => {
|
||||
@@ -756,7 +852,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
return null
|
||||
|
||||
setEditingThemeSlot (choice)
|
||||
applyDraftThemeSelection (choice, draftThemeSlots)
|
||||
applyDraftThemeSelection (choice, savedThemeSlots)
|
||||
return choice
|
||||
}
|
||||
|
||||
@@ -790,23 +886,23 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowRight':
|
||||
event.preventDefault ()
|
||||
setActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
|
||||
void requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
|
||||
break
|
||||
|
||||
case 'ArrowUp':
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault ()
|
||||
setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
|
||||
void requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
|
||||
break
|
||||
|
||||
case 'Home':
|
||||
event.preventDefault ()
|
||||
setActiveTab (tabs[0].id)
|
||||
void requestActiveTab (tabs[0].id)
|
||||
break
|
||||
|
||||
case 'End':
|
||||
event.preventDefault ()
|
||||
setActiveTab (tabs[tabs.length - 1].id)
|
||||
void requestActiveTab (tabs[tabs.length - 1].id)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -889,7 +985,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
)
|
||||
setClientActiveThemeSlot (draftActiveThemeSlot)
|
||||
setCachedUserThemeSlots (completeThemeSlots)
|
||||
applyThemeSelection (draftActiveThemeSlot, completeThemeSlots)
|
||||
applyClientAppearance (completeThemeSlots)
|
||||
toast ({ title: 'テーマ設定を保存しました.' })
|
||||
}
|
||||
catch (submitError)
|
||||
@@ -900,14 +996,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
}
|
||||
|
||||
const handleThemeDiscard = () => {
|
||||
setDraftActiveThemeSlot (savedActiveThemeSlot)
|
||||
setDraftThemeSlots (savedThemeSlots)
|
||||
setEditingThemeSlot (
|
||||
isUserThemeSlotSelection (savedActiveThemeSlot)
|
||||
? savedActiveThemeSlot
|
||||
: null,
|
||||
)
|
||||
applyThemeSelection (savedActiveThemeSlot, savedThemeSlots)
|
||||
discardThemeChanges ()
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
@@ -985,12 +1074,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
|
||||
useEffect (() => {
|
||||
if (userFieldErrors.name?.length)
|
||||
setActiveTab ('account')
|
||||
applyActiveTab ('account')
|
||||
}, [location.pathname, location.search, userFieldErrors.name])
|
||||
|
||||
useEffect (() => {
|
||||
if (settingsFieldErrors.theme?.length)
|
||||
setActiveTab ('theme')
|
||||
applyActiveTab ('theme')
|
||||
}, [location.pathname, location.search, settingsFieldErrors.theme])
|
||||
|
||||
useEffect (() => {
|
||||
@@ -1015,6 +1104,28 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
}
|
||||
: null
|
||||
|
||||
useEffect (() => {
|
||||
updateTabDirtyState ('theme', {
|
||||
dirty: hasUnsavedThemeChanges,
|
||||
discard: handleThemeDiscard,
|
||||
})
|
||||
}, [hasUnsavedThemeChanges, savedActiveThemeSlot, savedThemeSlots])
|
||||
|
||||
useKeyboardShortcuts ({
|
||||
'settings.account': () => {
|
||||
void requestActiveTab ('account')
|
||||
},
|
||||
'settings.behavior': () => {
|
||||
void requestActiveTab ('behavior')
|
||||
},
|
||||
'settings.theme': () => {
|
||||
void requestActiveTab ('theme')
|
||||
},
|
||||
'settings.keyboard': () => {
|
||||
void requestActiveTab ('keyboard')
|
||||
},
|
||||
})
|
||||
|
||||
const themeSectionProps: ThemeSectionProps = {
|
||||
draftActiveThemeSlot,
|
||||
draftThemeSlots,
|
||||
@@ -1025,16 +1136,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
settingsBaseErrors,
|
||||
settingsFieldErrors,
|
||||
onSelectThemeSlot: selection => {
|
||||
void confirmThemeSlotSwitch (themeSlots => {
|
||||
setEditingThemeSlot (null)
|
||||
applyDraftThemeSelection (selection, themeSlots)
|
||||
})
|
||||
void requestThemeSlotSelection (selection, null)
|
||||
},
|
||||
onEditThemeSlot: selection => {
|
||||
void confirmThemeSlotSwitch (themeSlots => {
|
||||
setEditingThemeSlot (selection)
|
||||
applyDraftThemeSelection (selection, themeSlots)
|
||||
})
|
||||
void requestThemeSlotSelection (selection, selection)
|
||||
},
|
||||
onEnsureEditableTheme: () => {
|
||||
void ensureEditableThemeSlot ()
|
||||
@@ -1067,7 +1172,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
}) ()
|
||||
},
|
||||
onResetThemeSlot: selection => {
|
||||
const baseTheme = draftThemeSlots[selection]?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId
|
||||
const baseTheme =
|
||||
draftThemeSlots[selection]?.baseTheme
|
||||
?? selection.split (':')[1] as BuiltinThemeId
|
||||
updateDraftThemeSlot (
|
||||
selection,
|
||||
() => getDefaultUserThemeSlot (
|
||||
@@ -1111,7 +1218,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
|
||||
: ['border-border bg-background text-foreground']),
|
||||
)}
|
||||
onClick={() => setActiveTab (tab.id)}>
|
||||
onClick={() => {
|
||||
void requestActiveTab (tab.id)
|
||||
}}>
|
||||
{tab.label}
|
||||
{settingsTabErrors[tab.id] ? ' !' : ''}
|
||||
</button>))}
|
||||
@@ -1125,9 +1234,17 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
<AccountSection {...accountSectionProps}/>)}
|
||||
{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
|
||||
{activeTab === 'keyboard' && (
|
||||
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
|
||||
<KeyboardSettingsSection
|
||||
sectionClassName={sectionClassName}
|
||||
onDirtyStateChange={(dirty, discard) => {
|
||||
updateTabDirtyState ('keyboard', { dirty, discard })
|
||||
}}/>)}
|
||||
{activeTab === 'behavior' && (
|
||||
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
|
||||
<BehaviourSettingsSection
|
||||
sectionClassName={sectionClassName}
|
||||
onDirtyStateChange={(dirty, discard) => {
|
||||
updateTabDirtyState ('behavior', { dirty, discard })
|
||||
}}/>)}
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
@@ -1155,7 +1272,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
: ['border-slate-300 bg-white text-slate-700',
|
||||
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']),
|
||||
)}
|
||||
onClick={() => setActiveTab (tab.id)}
|
||||
onClick={() => {
|
||||
void requestActiveTab (tab.id)
|
||||
}}
|
||||
onKeyDown={handleTabKeyDown}>
|
||||
{tab.label}
|
||||
{settingsTabErrors[tab.id] ? ' ⚠' : ''}
|
||||
@@ -1174,9 +1293,17 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
<AccountSection {...accountSectionProps}/>)}
|
||||
{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
|
||||
{activeTab === 'keyboard' && (
|
||||
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
|
||||
<KeyboardSettingsSection
|
||||
sectionClassName={sectionClassName}
|
||||
onDirtyStateChange={(dirty, discard) => {
|
||||
updateTabDirtyState ('keyboard', { dirty, discard })
|
||||
}}/>)}
|
||||
{activeTab === 'behavior' && (
|
||||
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
|
||||
<BehaviourSettingsSection
|
||||
sectionClassName={sectionClassName}
|
||||
onDirtyStateChange={(dirty, discard) => {
|
||||
updateTabDirtyState ('behavior', { dirty, discard })
|
||||
}}/>)}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
新しい課題から参照
ユーザをブロックする