@byline/admin 3.11.2 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/admin-account/components/container.js +12 -27
- package/dist/modules/admin-account/components/preferences.js +15 -13
- package/dist/modules/admin-account/components/theme-switch.d.ts +1 -0
- package/dist/modules/admin-account/components/theme-switch.js +54 -0
- package/dist/modules/admin-account/components/theme-switch.module.js +7 -0
- package/dist/modules/admin-account/components/theme-switch_module.css +16 -0
- package/dist/modules/admin-account/components/theme.d.ts +35 -0
- package/dist/modules/admin-account/components/theme.js +30 -0
- package/package.json +5 -5
- package/src/modules/admin-account/components/container.tsx +8 -16
- package/src/modules/admin-account/components/preferences.tsx +12 -12
- package/src/modules/admin-account/components/theme-switch.module.css +37 -0
- package/src/modules/admin-account/components/theme-switch.tsx +71 -0
- package/src/modules/admin-account/components/theme.ts +72 -0
|
@@ -3,11 +3,12 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { LocalDateTime } from "@byline/admin/react";
|
|
5
5
|
import { useTranslation } from "@byline/i18n/react";
|
|
6
|
-
import {
|
|
6
|
+
import { CloseIcon, Drawer, EditIcon, IconButton } from "@byline/ui/react";
|
|
7
7
|
import classnames from "classnames";
|
|
8
8
|
import { ChangeAccountPassword } from "./change-password.js";
|
|
9
9
|
import container_module from "./container.module.js";
|
|
10
10
|
import { Preferences } from "./preferences.js";
|
|
11
|
+
import { ThemeSwitch } from "./theme-switch.js";
|
|
11
12
|
import { UpdateAccount } from "./update.js";
|
|
12
13
|
const panelComponents = {
|
|
13
14
|
update: UpdateAccount,
|
|
@@ -121,7 +122,7 @@ function AccountSelfContainer({ account }) {
|
|
|
121
122
|
]
|
|
122
123
|
}),
|
|
123
124
|
/*#__PURE__*/ jsxs("p", {
|
|
124
|
-
className: classnames('byline-account-
|
|
125
|
+
className: classnames('byline-account-line', container_module.line),
|
|
125
126
|
children: [
|
|
126
127
|
/*#__PURE__*/ jsx("span", {
|
|
127
128
|
className: "muted",
|
|
@@ -134,11 +135,6 @@ function AccountSelfContainer({ account }) {
|
|
|
134
135
|
})
|
|
135
136
|
]
|
|
136
137
|
}),
|
|
137
|
-
/*#__PURE__*/ jsx(Button, {
|
|
138
|
-
size: "sm",
|
|
139
|
-
onClick: openDrawer('update'),
|
|
140
|
-
children: t('account.profile.editButton')
|
|
141
|
-
}),
|
|
142
138
|
/*#__PURE__*/ jsxs("div", {
|
|
143
139
|
className: classnames('muted', 'byline-account-meta', container_module.meta),
|
|
144
140
|
children: [
|
|
@@ -209,14 +205,6 @@ function AccountSelfContainer({ account }) {
|
|
|
209
205
|
})
|
|
210
206
|
]
|
|
211
207
|
}),
|
|
212
|
-
/*#__PURE__*/ jsx("p", {
|
|
213
|
-
className: classnames('byline-account-cta-line', container_module["cta-line"]),
|
|
214
|
-
children: /*#__PURE__*/ jsx(Button, {
|
|
215
|
-
size: "sm",
|
|
216
|
-
onClick: openDrawer('preferences'),
|
|
217
|
-
children: t('account.preferences.editButton')
|
|
218
|
-
})
|
|
219
|
-
}),
|
|
220
208
|
/*#__PURE__*/ jsx("p", {
|
|
221
209
|
className: classnames('muted', 'byline-account-status-help', container_module["status-help"]),
|
|
222
210
|
children: t('account.preferences.help')
|
|
@@ -228,21 +216,18 @@ function AccountSelfContainer({ account }) {
|
|
|
228
216
|
/*#__PURE__*/ jsxs("div", {
|
|
229
217
|
className: classnames('byline-account-column', container_module.column),
|
|
230
218
|
children: [
|
|
231
|
-
/*#__PURE__*/
|
|
219
|
+
/*#__PURE__*/ jsx(ContainerSection, {
|
|
232
220
|
title: t('account.sections.password'),
|
|
233
221
|
onEdit: openDrawer('change_password'),
|
|
234
222
|
editAriaLabel: editAriaFor(t('account.sections.password')),
|
|
235
|
-
children:
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
children: t('account.password.editButton')
|
|
244
|
-
})
|
|
245
|
-
]
|
|
223
|
+
children: /*#__PURE__*/ jsx("p", {
|
|
224
|
+
className: classnames('byline-account-line', container_module.line),
|
|
225
|
+
children: t('account.password.intro')
|
|
226
|
+
})
|
|
227
|
+
}),
|
|
228
|
+
/*#__PURE__*/ jsx(ContainerSection, {
|
|
229
|
+
title: t('account.sections.appearance'),
|
|
230
|
+
children: /*#__PURE__*/ jsx(ThemeSwitch, {})
|
|
246
231
|
}),
|
|
247
232
|
/*#__PURE__*/ jsxs(ContainerSection, {
|
|
248
233
|
title: t('account.sections.status'),
|
|
@@ -3,6 +3,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useMemo, useState } from "react";
|
|
4
4
|
import { revalidateLogic, useForm } from "@tanstack/react-form-start";
|
|
5
5
|
import { getClientConfig } from "@byline/core";
|
|
6
|
+
import { useTranslation } from "@byline/i18n/react";
|
|
6
7
|
import { Alert, Button, LoaderEllipsis, Select } from "@byline/ui/react";
|
|
7
8
|
import classnames from "classnames";
|
|
8
9
|
import { z } from "zod";
|
|
@@ -20,6 +21,7 @@ function defaultsFrom(account) {
|
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
23
|
function Preferences({ account, onClose, onSuccess }) {
|
|
24
|
+
const { t } = useTranslation('byline-admin');
|
|
23
25
|
const { setInterfaceLocale } = useBylineAdminServices();
|
|
24
26
|
const [formError, setFormError] = useState(null);
|
|
25
27
|
const [successMessage, setSuccessMessage] = useState(null);
|
|
@@ -36,11 +38,13 @@ function Preferences({ account, onClose, onSuccess }) {
|
|
|
36
38
|
return [
|
|
37
39
|
{
|
|
38
40
|
value: AUTO_VALUE,
|
|
39
|
-
label: '
|
|
41
|
+
label: t('language.useBrowserDefault')
|
|
40
42
|
},
|
|
41
43
|
...items
|
|
42
44
|
];
|
|
43
|
-
}, [
|
|
45
|
+
}, [
|
|
46
|
+
t
|
|
47
|
+
]);
|
|
44
48
|
const form = useForm({
|
|
45
49
|
defaultValues: defaultsFrom(account),
|
|
46
50
|
validationLogic: revalidateLogic({
|
|
@@ -54,19 +58,17 @@ function Preferences({ account, onClose, onSuccess }) {
|
|
|
54
58
|
setFormError(null);
|
|
55
59
|
setSuccessMessage(null);
|
|
56
60
|
const nextLocale = value.locale === AUTO_VALUE ? null : value.locale;
|
|
57
|
-
if (nextLocale === (account.preferred_locale ?? null)) return void setSuccessMessage('
|
|
61
|
+
if (nextLocale === (account.preferred_locale ?? null)) return void setSuccessMessage(t('common.feedback.noChanges'));
|
|
58
62
|
try {
|
|
59
63
|
const result = await setInterfaceLocale({
|
|
60
64
|
data: {
|
|
61
65
|
locale: nextLocale
|
|
62
66
|
}
|
|
63
67
|
});
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
onSuccess?.(result.account);
|
|
67
|
-
} else setSuccessMessage('Saved.');
|
|
68
|
+
setSuccessMessage(t('common.feedback.saved'));
|
|
69
|
+
if (null != result.account) onSuccess?.(result.account);
|
|
68
70
|
} catch {
|
|
69
|
-
setFormError('
|
|
71
|
+
setFormError(t('common.errors.couldNotSave'));
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
});
|
|
@@ -98,12 +100,12 @@ function Preferences({ account, onClose, onSuccess }) {
|
|
|
98
100
|
/*#__PURE__*/ jsx("label", {
|
|
99
101
|
htmlFor: "preferences-locale-select",
|
|
100
102
|
className: classnames('byline-account-preferences-label', preferences_module.label),
|
|
101
|
-
children:
|
|
103
|
+
children: t('account.preferences.languageLabel')
|
|
102
104
|
}),
|
|
103
105
|
/*#__PURE__*/ jsx(Select, {
|
|
104
106
|
id: "preferences-locale-select",
|
|
105
107
|
size: "sm",
|
|
106
|
-
ariaLabel:
|
|
108
|
+
ariaLabel: t('account.preferences.languageLabel'),
|
|
107
109
|
value: field.state.value,
|
|
108
110
|
items: localeItems,
|
|
109
111
|
onValueChange: (value)=>{
|
|
@@ -112,7 +114,7 @@ function Preferences({ account, onClose, onSuccess }) {
|
|
|
112
114
|
}),
|
|
113
115
|
/*#__PURE__*/ jsx("p", {
|
|
114
116
|
className: classnames('muted', 'byline-account-preferences-help', preferences_module.help),
|
|
115
|
-
children: '
|
|
117
|
+
children: t('account.preferences.languageHelp')
|
|
116
118
|
})
|
|
117
119
|
]
|
|
118
120
|
})
|
|
@@ -126,7 +128,7 @@ function Preferences({ account, onClose, onSuccess }) {
|
|
|
126
128
|
size: "sm",
|
|
127
129
|
onClick: onClose,
|
|
128
130
|
className: classnames('byline-account-preferences-action', preferences_module.action),
|
|
129
|
-
children: successMessage ? '
|
|
131
|
+
children: successMessage ? t('common.actions.close') : t('common.actions.cancel')
|
|
130
132
|
}),
|
|
131
133
|
/*#__PURE__*/ jsx(form.Subscribe, {
|
|
132
134
|
selector: (state)=>({
|
|
@@ -141,7 +143,7 @@ function Preferences({ account, onClose, onSuccess }) {
|
|
|
141
143
|
className: classnames('byline-account-preferences-action', preferences_module.action),
|
|
142
144
|
children: true === isSubmitting ? /*#__PURE__*/ jsx(LoaderEllipsis, {
|
|
143
145
|
size: 42
|
|
144
|
-
}) : '
|
|
146
|
+
}) : t('common.actions.save')
|
|
145
147
|
})
|
|
146
148
|
})
|
|
147
149
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ThemeSwitch(): import("react").JSX.Element;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useTranslation } from "@byline/i18n/react";
|
|
5
|
+
import { ButtonGroup, ButtonGroupItem } from "@byline/ui/react";
|
|
6
|
+
import classnames from "classnames";
|
|
7
|
+
import { getThemeMode, setThemeMode } from "./theme.js";
|
|
8
|
+
import theme_switch_module from "./theme-switch.module.js";
|
|
9
|
+
function ThemeSwitch() {
|
|
10
|
+
const { t } = useTranslation('byline-admin');
|
|
11
|
+
const [mode, setMode] = useState('system');
|
|
12
|
+
useEffect(()=>{
|
|
13
|
+
setMode(getThemeMode());
|
|
14
|
+
}, []);
|
|
15
|
+
const handleChange = (value)=>{
|
|
16
|
+
const next = Array.isArray(value) ? value[0] : value;
|
|
17
|
+
if ('light' !== next && 'dark' !== next && 'system' !== next) return;
|
|
18
|
+
setMode(next);
|
|
19
|
+
setThemeMode(next);
|
|
20
|
+
};
|
|
21
|
+
return /*#__PURE__*/ jsxs("div", {
|
|
22
|
+
className: classnames('byline-account-theme-switch', theme_switch_module.wrap),
|
|
23
|
+
children: [
|
|
24
|
+
/*#__PURE__*/ jsxs(ButtonGroup, {
|
|
25
|
+
type: "single",
|
|
26
|
+
size: "sm",
|
|
27
|
+
expandToFit: true,
|
|
28
|
+
value: mode,
|
|
29
|
+
onValueChange: handleChange,
|
|
30
|
+
"aria-label": t('account.appearance.colorTheme'),
|
|
31
|
+
className: classnames('byline-account-theme-group', theme_switch_module.group),
|
|
32
|
+
children: [
|
|
33
|
+
/*#__PURE__*/ jsx(ButtonGroupItem, {
|
|
34
|
+
value: "light",
|
|
35
|
+
children: t('account.appearance.light')
|
|
36
|
+
}),
|
|
37
|
+
/*#__PURE__*/ jsx(ButtonGroupItem, {
|
|
38
|
+
value: "dark",
|
|
39
|
+
children: t('account.appearance.dark')
|
|
40
|
+
}),
|
|
41
|
+
/*#__PURE__*/ jsx(ButtonGroupItem, {
|
|
42
|
+
value: "system",
|
|
43
|
+
children: t('account.appearance.system')
|
|
44
|
+
})
|
|
45
|
+
]
|
|
46
|
+
}),
|
|
47
|
+
/*#__PURE__*/ jsx("p", {
|
|
48
|
+
className: classnames('muted', 'byline-account-theme-help', theme_switch_module.help),
|
|
49
|
+
children: t('account.appearance.help')
|
|
50
|
+
})
|
|
51
|
+
]
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export { ThemeSwitch };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
:is(.wrap-Nbrlzw, .byline-account-theme-switch) {
|
|
2
|
+
gap: var(--spacing-8);
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
display: flex;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
:is(.group-FhIRi0, .byline-account-theme-group) {
|
|
8
|
+
width: 100%;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
:is(.help-SiI4LX, .byline-account-theme-help) {
|
|
12
|
+
margin-top: var(--spacing-4);
|
|
13
|
+
font-size: var(--font-size-xs);
|
|
14
|
+
margin-bottom: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Client-side colour-theme control for the self-service account.
|
|
10
|
+
*
|
|
11
|
+
* Reads and writes Byline's host theme contract — the same convention
|
|
12
|
+
* the host's blocking `EarlyThemeDetector` script (rendered into
|
|
13
|
+
* `<head>`) and the host ThemeProvider already use:
|
|
14
|
+
*
|
|
15
|
+
* - `localStorage['theme'] === 'dark' | 'light'` — an explicit choice.
|
|
16
|
+
* - `localStorage['theme']` absent — follow the OS
|
|
17
|
+
* (`prefers-color-scheme`), i.e. "system".
|
|
18
|
+
* - the effective theme is applied as a `.dark` / `.light` class on
|
|
19
|
+
* `<html>`, plus `style.colorScheme` and the `meta[name=color-scheme]`
|
|
20
|
+
* content.
|
|
21
|
+
*
|
|
22
|
+
* A choice made here is therefore honoured by the detector on the next
|
|
23
|
+
* load and by the provider on the next route change, and is applied to
|
|
24
|
+
* the live document immediately. Deliberately tiny and dependency-free;
|
|
25
|
+
* if a host ever diverges from this convention, this is the single seam
|
|
26
|
+
* to revisit.
|
|
27
|
+
*/
|
|
28
|
+
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
29
|
+
/** The persisted choice. Absent storage resolves to `'system'`. */
|
|
30
|
+
export declare function getThemeMode(): ThemeMode;
|
|
31
|
+
/**
|
|
32
|
+
* Persist and apply a theme choice. `'system'` clears the stored
|
|
33
|
+
* override so the document follows `prefers-color-scheme` again.
|
|
34
|
+
*/
|
|
35
|
+
export declare function setThemeMode(mode: ThemeMode): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const STORAGE_KEY = 'theme';
|
|
2
|
+
const PREFERS_DARK = '(prefers-color-scheme: dark)';
|
|
3
|
+
function getThemeMode() {
|
|
4
|
+
if ("u" < typeof localStorage) return 'system';
|
|
5
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
6
|
+
return 'dark' === stored || 'light' === stored ? stored : 'system';
|
|
7
|
+
}
|
|
8
|
+
function systemPrefersDark() {
|
|
9
|
+
return "u" > typeof window && window.matchMedia(PREFERS_DARK).matches;
|
|
10
|
+
}
|
|
11
|
+
function applyEffectiveTheme(effective) {
|
|
12
|
+
if ("u" < typeof document) return;
|
|
13
|
+
const root = document.documentElement;
|
|
14
|
+
root.classList.toggle('dark', 'dark' === effective);
|
|
15
|
+
root.classList.toggle('light', 'light' === effective);
|
|
16
|
+
root.style.colorScheme = effective;
|
|
17
|
+
const meta = document.querySelector('meta[name="color-scheme"]');
|
|
18
|
+
if (null != meta) meta.setAttribute('content', 'dark' === effective ? 'dark light' : 'light dark');
|
|
19
|
+
}
|
|
20
|
+
function setThemeMode(mode) {
|
|
21
|
+
if ("u" < typeof window) return;
|
|
22
|
+
if ('system' === mode) {
|
|
23
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
24
|
+
applyEffectiveTheme(systemPrefersDark() ? 'dark' : 'light');
|
|
25
|
+
} else {
|
|
26
|
+
localStorage.setItem(STORAGE_KEY, mode);
|
|
27
|
+
applyEffectiveTheme(mode);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export { getThemeMode, setThemeMode };
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.12.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -151,10 +151,10 @@
|
|
|
151
151
|
"uuid": "^14.0.0",
|
|
152
152
|
"zod": "^4.4.3",
|
|
153
153
|
"zod-form-data": "^3.0.1",
|
|
154
|
-
"@byline/
|
|
155
|
-
"@byline/
|
|
156
|
-
"@byline/
|
|
157
|
-
"@byline/i18n": "3.
|
|
154
|
+
"@byline/ui": "3.12.0",
|
|
155
|
+
"@byline/auth": "3.12.0",
|
|
156
|
+
"@byline/core": "3.12.0",
|
|
157
|
+
"@byline/i18n": "3.12.0"
|
|
158
158
|
},
|
|
159
159
|
"peerDependencies": {
|
|
160
160
|
"react": "^19.0.0",
|
|
@@ -29,12 +29,13 @@ import { useState } from 'react'
|
|
|
29
29
|
|
|
30
30
|
import { LocalDateTime } from '@byline/admin/react'
|
|
31
31
|
import { useTranslation } from '@byline/i18n/react'
|
|
32
|
-
import {
|
|
32
|
+
import { CloseIcon, Drawer, EditIcon, IconButton } from '@byline/ui/react'
|
|
33
33
|
import cx from 'classnames'
|
|
34
34
|
|
|
35
35
|
import { ChangeAccountPassword } from './change-password.js'
|
|
36
36
|
import styles from './container.module.css'
|
|
37
37
|
import { Preferences } from './preferences.js'
|
|
38
|
+
import { ThemeSwitch } from './theme-switch.js'
|
|
38
39
|
import { UpdateAccount } from './update.js'
|
|
39
40
|
import type { AccountResponse } from '../index.js'
|
|
40
41
|
|
|
@@ -139,7 +140,7 @@ export function AccountSelfContainer({ account }: AccountSelfContainerProps) {
|
|
|
139
140
|
</span>
|
|
140
141
|
)}
|
|
141
142
|
</p>
|
|
142
|
-
<p className={cx('byline-account-
|
|
143
|
+
<p className={cx('byline-account-line', styles.line)}>
|
|
143
144
|
<span className="muted">{t('account.profile.username')}</span>{' '}
|
|
144
145
|
{currentAccount.username ?? (
|
|
145
146
|
<span className={cx('muted', 'byline-account-not-set', styles['not-set'])}>
|
|
@@ -147,9 +148,6 @@ export function AccountSelfContainer({ account }: AccountSelfContainerProps) {
|
|
|
147
148
|
</span>
|
|
148
149
|
)}
|
|
149
150
|
</p>
|
|
150
|
-
<Button size="sm" onClick={openDrawer('update')}>
|
|
151
|
-
{t('account.profile.editButton')}
|
|
152
|
-
</Button>
|
|
153
151
|
<div className={cx('muted', 'byline-account-meta', styles.meta)}>
|
|
154
152
|
<p>
|
|
155
153
|
<span className="font-bold">{t('account.profile.created')} </span>
|
|
@@ -179,11 +177,6 @@ export function AccountSelfContainer({ account }: AccountSelfContainerProps) {
|
|
|
179
177
|
</span>
|
|
180
178
|
)}
|
|
181
179
|
</p>
|
|
182
|
-
<p className={cx('byline-account-cta-line', styles['cta-line'])}>
|
|
183
|
-
<Button size="sm" onClick={openDrawer('preferences')}>
|
|
184
|
-
{t('account.preferences.editButton')}
|
|
185
|
-
</Button>
|
|
186
|
-
</p>
|
|
187
180
|
<p className={cx('muted', 'byline-account-status-help', styles['status-help'])}>
|
|
188
181
|
{t('account.preferences.help')}
|
|
189
182
|
</p>
|
|
@@ -196,12 +189,11 @@ export function AccountSelfContainer({ account }: AccountSelfContainerProps) {
|
|
|
196
189
|
onEdit={openDrawer('change_password')}
|
|
197
190
|
editAriaLabel={editAriaFor(t('account.sections.password'))}
|
|
198
191
|
>
|
|
199
|
-
<p className={cx('byline-account-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
</Button>
|
|
192
|
+
<p className={cx('byline-account-line', styles.line)}>{t('account.password.intro')}</p>
|
|
193
|
+
</ContainerSection>
|
|
194
|
+
|
|
195
|
+
<ContainerSection title={t('account.sections.appearance')}>
|
|
196
|
+
<ThemeSwitch />
|
|
205
197
|
</ContainerSection>
|
|
206
198
|
|
|
207
199
|
<ContainerSection title={t('account.sections.status')}>
|
|
@@ -37,6 +37,7 @@ import { useMemo, useState } from 'react'
|
|
|
37
37
|
import { revalidateLogic, useForm } from '@tanstack/react-form-start'
|
|
38
38
|
|
|
39
39
|
import { getClientConfig } from '@byline/core'
|
|
40
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
40
41
|
import { Alert, Button, LoaderEllipsis, Select } from '@byline/ui/react'
|
|
41
42
|
import cx from 'classnames'
|
|
42
43
|
import { z } from 'zod'
|
|
@@ -74,6 +75,7 @@ interface PreferencesProps {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
export function Preferences({ account, onClose, onSuccess }: PreferencesProps) {
|
|
78
|
+
const { t } = useTranslation('byline-admin')
|
|
77
79
|
const { setInterfaceLocale } = useBylineAdminServices()
|
|
78
80
|
const [formError, setFormError] = useState<string | null>(null)
|
|
79
81
|
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
|
@@ -91,8 +93,8 @@ export function Preferences({ account, onClose, onSuccess }: PreferencesProps) {
|
|
|
91
93
|
value: code,
|
|
92
94
|
label: definitionByCode.get(code) ?? code,
|
|
93
95
|
}))
|
|
94
|
-
return [{ value: AUTO_VALUE, label: '
|
|
95
|
-
}, [])
|
|
96
|
+
return [{ value: AUTO_VALUE, label: t('language.useBrowserDefault') }, ...items]
|
|
97
|
+
}, [t])
|
|
96
98
|
|
|
97
99
|
const form = useForm({
|
|
98
100
|
defaultValues: defaultsFrom(account),
|
|
@@ -108,7 +110,7 @@ export function Preferences({ account, onClose, onSuccess }: PreferencesProps) {
|
|
|
108
110
|
setSuccessMessage(null)
|
|
109
111
|
const nextLocale = value.locale === AUTO_VALUE ? null : value.locale
|
|
110
112
|
if (nextLocale === (account.preferred_locale ?? null)) {
|
|
111
|
-
setSuccessMessage('
|
|
113
|
+
setSuccessMessage(t('common.feedback.noChanges'))
|
|
112
114
|
return
|
|
113
115
|
}
|
|
114
116
|
try {
|
|
@@ -116,14 +118,12 @@ export function Preferences({ account, onClose, onSuccess }: PreferencesProps) {
|
|
|
116
118
|
// Pre-auth path returns `account: null`; the form is only
|
|
117
119
|
// reachable behind an admin session, so `account` should be
|
|
118
120
|
// populated. Treat absence as a defensive no-op.
|
|
121
|
+
setSuccessMessage(t('common.feedback.saved'))
|
|
119
122
|
if (result.account != null) {
|
|
120
|
-
setSuccessMessage('Saved.')
|
|
121
123
|
onSuccess?.(result.account)
|
|
122
|
-
} else {
|
|
123
|
-
setSuccessMessage('Saved.')
|
|
124
124
|
}
|
|
125
125
|
} catch {
|
|
126
|
-
setFormError('
|
|
126
|
+
setFormError(t('common.errors.couldNotSave'))
|
|
127
127
|
}
|
|
128
128
|
},
|
|
129
129
|
})
|
|
@@ -150,12 +150,12 @@ export function Preferences({ account, onClose, onSuccess }: PreferencesProps) {
|
|
|
150
150
|
htmlFor="preferences-locale-select"
|
|
151
151
|
className={cx('byline-account-preferences-label', styles.label)}
|
|
152
152
|
>
|
|
153
|
-
|
|
153
|
+
{t('account.preferences.languageLabel')}
|
|
154
154
|
</label>
|
|
155
155
|
<Select<string>
|
|
156
156
|
id="preferences-locale-select"
|
|
157
157
|
size="sm"
|
|
158
|
-
ariaLabel=
|
|
158
|
+
ariaLabel={t('account.preferences.languageLabel')}
|
|
159
159
|
value={field.state.value}
|
|
160
160
|
items={localeItems}
|
|
161
161
|
onValueChange={(value) => {
|
|
@@ -163,7 +163,7 @@ export function Preferences({ account, onClose, onSuccess }: PreferencesProps) {
|
|
|
163
163
|
}}
|
|
164
164
|
/>
|
|
165
165
|
<p className={cx('muted', 'byline-account-preferences-help', styles.help)}>
|
|
166
|
-
|
|
166
|
+
{t('account.preferences.languageHelp')}
|
|
167
167
|
</p>
|
|
168
168
|
</div>
|
|
169
169
|
)}
|
|
@@ -177,7 +177,7 @@ export function Preferences({ account, onClose, onSuccess }: PreferencesProps) {
|
|
|
177
177
|
onClick={onClose}
|
|
178
178
|
className={cx('byline-account-preferences-action', styles.action)}
|
|
179
179
|
>
|
|
180
|
-
{successMessage ? '
|
|
180
|
+
{successMessage ? t('common.actions.close') : t('common.actions.cancel')}
|
|
181
181
|
</Button>
|
|
182
182
|
<form.Subscribe
|
|
183
183
|
selector={(state) => ({
|
|
@@ -193,7 +193,7 @@ export function Preferences({ account, onClose, onSuccess }: PreferencesProps) {
|
|
|
193
193
|
disabled={!canSubmit || isSubmitting}
|
|
194
194
|
className={cx('byline-account-preferences-action', styles.action)}
|
|
195
195
|
>
|
|
196
|
-
{isSubmitting === true ? <LoaderEllipsis size={42} /> : '
|
|
196
|
+
{isSubmitting === true ? <LoaderEllipsis size={42} /> : t('common.actions.save')}
|
|
197
197
|
</Button>
|
|
198
198
|
)}
|
|
199
199
|
</form.Subscribe>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* ThemeSwitch — the segmented control fills the card width; the help
|
|
11
|
+
* line below it matches the muted helper styling used elsewhere on the
|
|
12
|
+
* account cards.
|
|
13
|
+
*
|
|
14
|
+
* Override handles:
|
|
15
|
+
* .byline-account-theme-switch — wrapper
|
|
16
|
+
* .byline-account-theme-group — the segmented control
|
|
17
|
+
* .byline-account-theme-help — muted helper line
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
.wrap,
|
|
21
|
+
:global(.byline-account-theme-switch) {
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-direction: column;
|
|
24
|
+
gap: var(--spacing-8);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.group,
|
|
28
|
+
:global(.byline-account-theme-group) {
|
|
29
|
+
width: 100%;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.help,
|
|
33
|
+
:global(.byline-account-theme-help) {
|
|
34
|
+
margin-top: var(--spacing-4);
|
|
35
|
+
margin-bottom: 0;
|
|
36
|
+
font-size: var(--font-size-xs);
|
|
37
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Inline colour-theme switch for the account "Appearance" card — a
|
|
13
|
+
* three-way segmented control (Light / Dark / System). Unlike the other
|
|
14
|
+
* account sections it edits in place rather than through the drawer, so
|
|
15
|
+
* there's no pencil affordance on its card. See `./theme.ts` for the
|
|
16
|
+
* host theme contract this reads and writes.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useEffect, useState } from 'react'
|
|
20
|
+
|
|
21
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
22
|
+
import { ButtonGroup, ButtonGroupItem } from '@byline/ui/react'
|
|
23
|
+
import cx from 'classnames'
|
|
24
|
+
|
|
25
|
+
import { getThemeMode, setThemeMode, type ThemeMode } from './theme.js'
|
|
26
|
+
import styles from './theme-switch.module.css'
|
|
27
|
+
|
|
28
|
+
export function ThemeSwitch() {
|
|
29
|
+
const { t } = useTranslation('byline-admin')
|
|
30
|
+
|
|
31
|
+
// SSR and the first client render both show 'system' — a deterministic
|
|
32
|
+
// value, so there's no hydration mismatch. The actual stored mode is
|
|
33
|
+
// read once after mount (localStorage is client-only).
|
|
34
|
+
const [mode, setMode] = useState<ThemeMode>('system')
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
setMode(getThemeMode())
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
const handleChange = (value: string | string[]) => {
|
|
41
|
+
// `ButtonGroup`'s union props type the callback as `string | string[]`;
|
|
42
|
+
// in single-select mode it's a string. A single-select ToggleGroup
|
|
43
|
+
// also emits '' when the active item is re-clicked (deselect) — a
|
|
44
|
+
// theme is always set, so ignore anything that isn't a known mode.
|
|
45
|
+
const next = Array.isArray(value) ? value[0] : value
|
|
46
|
+
if (next !== 'light' && next !== 'dark' && next !== 'system') return
|
|
47
|
+
setMode(next)
|
|
48
|
+
setThemeMode(next)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={cx('byline-account-theme-switch', styles.wrap)}>
|
|
53
|
+
<ButtonGroup
|
|
54
|
+
type="single"
|
|
55
|
+
size="sm"
|
|
56
|
+
expandToFit
|
|
57
|
+
value={mode}
|
|
58
|
+
onValueChange={handleChange}
|
|
59
|
+
aria-label={t('account.appearance.colorTheme')}
|
|
60
|
+
className={cx('byline-account-theme-group', styles.group)}
|
|
61
|
+
>
|
|
62
|
+
<ButtonGroupItem value="light">{t('account.appearance.light')}</ButtonGroupItem>
|
|
63
|
+
<ButtonGroupItem value="dark">{t('account.appearance.dark')}</ButtonGroupItem>
|
|
64
|
+
<ButtonGroupItem value="system">{t('account.appearance.system')}</ButtonGroupItem>
|
|
65
|
+
</ButtonGroup>
|
|
66
|
+
<p className={cx('muted', 'byline-account-theme-help', styles.help)}>
|
|
67
|
+
{t('account.appearance.help')}
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Client-side colour-theme control for the self-service account.
|
|
11
|
+
*
|
|
12
|
+
* Reads and writes Byline's host theme contract — the same convention
|
|
13
|
+
* the host's blocking `EarlyThemeDetector` script (rendered into
|
|
14
|
+
* `<head>`) and the host ThemeProvider already use:
|
|
15
|
+
*
|
|
16
|
+
* - `localStorage['theme'] === 'dark' | 'light'` — an explicit choice.
|
|
17
|
+
* - `localStorage['theme']` absent — follow the OS
|
|
18
|
+
* (`prefers-color-scheme`), i.e. "system".
|
|
19
|
+
* - the effective theme is applied as a `.dark` / `.light` class on
|
|
20
|
+
* `<html>`, plus `style.colorScheme` and the `meta[name=color-scheme]`
|
|
21
|
+
* content.
|
|
22
|
+
*
|
|
23
|
+
* A choice made here is therefore honoured by the detector on the next
|
|
24
|
+
* load and by the provider on the next route change, and is applied to
|
|
25
|
+
* the live document immediately. Deliberately tiny and dependency-free;
|
|
26
|
+
* if a host ever diverges from this convention, this is the single seam
|
|
27
|
+
* to revisit.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export type ThemeMode = 'light' | 'dark' | 'system'
|
|
31
|
+
|
|
32
|
+
const STORAGE_KEY = 'theme'
|
|
33
|
+
const PREFERS_DARK = '(prefers-color-scheme: dark)'
|
|
34
|
+
|
|
35
|
+
/** The persisted choice. Absent storage resolves to `'system'`. */
|
|
36
|
+
export function getThemeMode(): ThemeMode {
|
|
37
|
+
if (typeof localStorage === 'undefined') return 'system'
|
|
38
|
+
const stored = localStorage.getItem(STORAGE_KEY)
|
|
39
|
+
return stored === 'dark' || stored === 'light' ? stored : 'system'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function systemPrefersDark(): boolean {
|
|
43
|
+
return typeof window !== 'undefined' && window.matchMedia(PREFERS_DARK).matches
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Apply the effective theme to `<html>`, mirroring the host detector. */
|
|
47
|
+
function applyEffectiveTheme(effective: 'light' | 'dark'): void {
|
|
48
|
+
if (typeof document === 'undefined') return
|
|
49
|
+
const root = document.documentElement
|
|
50
|
+
root.classList.toggle('dark', effective === 'dark')
|
|
51
|
+
root.classList.toggle('light', effective === 'light')
|
|
52
|
+
root.style.colorScheme = effective
|
|
53
|
+
const meta = document.querySelector('meta[name="color-scheme"]')
|
|
54
|
+
if (meta != null) {
|
|
55
|
+
meta.setAttribute('content', effective === 'dark' ? 'dark light' : 'light dark')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Persist and apply a theme choice. `'system'` clears the stored
|
|
61
|
+
* override so the document follows `prefers-color-scheme` again.
|
|
62
|
+
*/
|
|
63
|
+
export function setThemeMode(mode: ThemeMode): void {
|
|
64
|
+
if (typeof window === 'undefined') return
|
|
65
|
+
if (mode === 'system') {
|
|
66
|
+
localStorage.removeItem(STORAGE_KEY)
|
|
67
|
+
applyEffectiveTheme(systemPrefersDark() ? 'dark' : 'light')
|
|
68
|
+
} else {
|
|
69
|
+
localStorage.setItem(STORAGE_KEY, mode)
|
|
70
|
+
applyEffectiveTheme(mode)
|
|
71
|
+
}
|
|
72
|
+
}
|