@byline/admin 3.11.3 → 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.
@@ -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 { Button, CloseIcon, Drawer, EditIcon, IconButton } from "@byline/ui/react";
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-cta-line', container_module["cta-line"]),
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__*/ jsxs(ContainerSection, {
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
- /*#__PURE__*/ jsx("p", {
237
- className: classnames('byline-account-cta-line', container_module["cta-line"]),
238
- children: t('account.password.intro')
239
- }),
240
- /*#__PURE__*/ jsx(Button, {
241
- size: "sm",
242
- onClick: openDrawer('change_password'),
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: 'Use browser default'
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('No changes to save.');
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
- if (null != result.account) {
65
- setSuccessMessage('Saved.');
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('Could not save changes. Please try again.');
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: "Interface language"
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: "Interface language",
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: 'Choose "Use browser default" to let your browser\'s language settings decide.'
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 ? 'Close' : 'Cancel'
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
- }) : 'Save'
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,7 @@
1
+ import "./theme-switch_module.css";
2
+ const theme_switch_module = {
3
+ wrap: "wrap-Nbrlzw",
4
+ group: "group-FhIRi0",
5
+ help: "help-SiI4LX"
6
+ };
7
+ export default theme_switch_module;
@@ -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.11.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/auth": "3.11.3",
155
- "@byline/core": "3.11.3",
156
- "@byline/i18n": "3.11.3",
157
- "@byline/ui": "3.11.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 { Button, CloseIcon, Drawer, EditIcon, IconButton } from '@byline/ui/react'
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-cta-line', styles['cta-line'])}>
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')}&nbsp;</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-cta-line', styles['cta-line'])}>
200
- {t('account.password.intro')}
201
- </p>
202
- <Button size="sm" onClick={openDrawer('change_password')}>
203
- {t('account.password.editButton')}
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: 'Use browser default' }, ...items]
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('No changes to save.')
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('Could not save changes. Please try again.')
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
- Interface language
153
+ {t('account.preferences.languageLabel')}
154
154
  </label>
155
155
  <Select<string>
156
156
  id="preferences-locale-select"
157
157
  size="sm"
158
- ariaLabel="Interface language"
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
- Choose "Use browser default" to let your browser's language settings decide.
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 ? 'Close' : 'Cancel'}
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} /> : 'Save'}
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
+ }