зеркало из https://github.com/mozilla/pontoon.git
Add Theme Switching Functionality to Translate View (#3002)
Co-authored-by: Matjaž Horvat <matjaz.horvat@gmail.com>
This commit is contained in:
Родитель
a2d3d2e28e
Коммит
ee337e7f5c
|
@ -888,6 +888,7 @@ def user_data(request):
|
|||
"gravatar_url_small": user.gravatar_url(88),
|
||||
"gravatar_url_big": user.gravatar_url(176),
|
||||
"notifications": user.serialized_notifications,
|
||||
"theme": user.profile.theme,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -723,6 +723,14 @@ user-UserAvatar--alt-text =
|
|||
user-SignIn--sign-in = Sign in
|
||||
user-SignOut--sign-out = <glyph></glyph>Sign out
|
||||
|
||||
user-UserMenu--appearance-title = Choose appearance
|
||||
user-UserMenu--appearance-dark = <glyph></glyph> Dark
|
||||
.title = Use a dark theme
|
||||
user-UserMenu--appearance-light = <glyph></glyph> Light
|
||||
.title = Use a light theme
|
||||
user-UserMenu--appearance-system = <glyph></glyph> System
|
||||
.title = Use a theme that matches your system settings
|
||||
|
||||
user-UserMenu--download-terminology = <glyph></glyph>Download Terminology
|
||||
user-UserMenu--download-tm = <glyph></glyph>Download Translation Memory
|
||||
user-UserMenu--download-translations = <glyph></glyph>Download Translations
|
||||
|
|
|
@ -93,6 +93,19 @@ export function updateUserSetting(
|
|||
return POST(`/api/v1/user/${username}/`, payload, { headers });
|
||||
}
|
||||
|
||||
export function updateUserTheme(
|
||||
username: string,
|
||||
theme: string,
|
||||
): Promise<void> {
|
||||
const csrfToken = getCSRFToken();
|
||||
const payload = new URLSearchParams({
|
||||
theme,
|
||||
csrfmiddlewaretoken: csrfToken,
|
||||
});
|
||||
const headers = new Headers({ 'X-CSRFToken': csrfToken });
|
||||
return POST(`/api/v1/user/${username}/theme/`, payload, { headers });
|
||||
}
|
||||
|
||||
/** Update Interactive Tour status to a given step. */
|
||||
export function updateTourStatus(step: number): Promise<void> {
|
||||
const csrfToken = getCSRFToken();
|
||||
|
|
|
@ -1,39 +1,20 @@
|
|||
import { createContext, useEffect, useState } from 'react';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
export const ThemeContext = createContext({
|
||||
theme: 'system',
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
function getSystemTheme() {
|
||||
if (
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
return 'dark';
|
||||
} else {
|
||||
return 'light';
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactElement }) {
|
||||
const [theme] = useState(
|
||||
() => document.body.getAttribute('data-theme') || 'dark',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function applyTheme(newTheme: string) {
|
||||
if (newTheme === 'system') {
|
||||
newTheme = getSystemTheme();
|
||||
}
|
||||
document.body.classList.remove(
|
||||
'dark-theme',
|
||||
'light-theme',
|
||||
'system-theme',
|
||||
);
|
||||
document.body.classList.add(`${newTheme}-theme`);
|
||||
}
|
||||
const applyTheme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
function handleThemeChange(e: MediaQueryListEvent) {
|
||||
let userThemeSetting = document.body.getAttribute('data-theme') || 'dark';
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
export function useTheme() {
|
||||
function getSystemTheme(): string {
|
||||
if (
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
return 'dark';
|
||||
} else {
|
||||
return 'light';
|
||||
}
|
||||
}
|
||||
|
||||
return function (newTheme: string) {
|
||||
if (newTheme === 'system') {
|
||||
newTheme = getSystemTheme();
|
||||
}
|
||||
document.body.classList.remove('dark-theme', 'light-theme', 'system-theme');
|
||||
document.body.classList.add(`${newTheme}-theme`);
|
||||
};
|
||||
}
|
|
@ -3,6 +3,7 @@ import {
|
|||
fetchUserData,
|
||||
markAllNotificationsAsRead,
|
||||
updateUserSetting,
|
||||
updateUserTheme,
|
||||
} from '~/api/user';
|
||||
import { NotificationMessage } from '~/context/Notification';
|
||||
import {
|
||||
|
@ -15,8 +16,9 @@ import type { AppThunk } from '~/store';
|
|||
|
||||
export const UPDATE = 'user/UPDATE';
|
||||
export const UPDATE_SETTINGS = 'user/UPDATE_SETTINGS';
|
||||
export const UPDATE_THEME = 'user/UPDATE_THEME';
|
||||
|
||||
export type Action = UpdateAction | UpdateSettingsAction;
|
||||
export type Action = UpdateAction | UpdateSettingsAction | UpdateThemeAction;
|
||||
|
||||
/**
|
||||
* Update the user data.
|
||||
|
@ -39,6 +41,14 @@ export type UpdateSettingsAction = {
|
|||
readonly settings: Settings;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the user theme.
|
||||
*/
|
||||
export type UpdateThemeAction = {
|
||||
readonly type: typeof UPDATE_THEME;
|
||||
readonly theme: string;
|
||||
};
|
||||
|
||||
function getNotification(setting: keyof Settings, value: boolean) {
|
||||
switch (setting) {
|
||||
case 'runQualityChecks':
|
||||
|
@ -65,6 +75,14 @@ export function saveSetting(
|
|||
};
|
||||
}
|
||||
|
||||
export function saveTheme(theme: string, username: string): AppThunk {
|
||||
return async (dispatch) => {
|
||||
await updateUserTheme(username, theme);
|
||||
|
||||
dispatch({ type: UPDATE_THEME, theme });
|
||||
};
|
||||
}
|
||||
|
||||
export const markAllNotificationsAsRead_ = (): AppThunk => async (dispatch) => {
|
||||
await markAllNotificationsAsRead();
|
||||
dispatch(getUserData());
|
||||
|
|
|
@ -10,15 +10,23 @@ import './UserControls.css';
|
|||
import { UserNotificationsMenu } from './UserNotificationsMenu';
|
||||
import { UserMenu } from './UserMenu';
|
||||
|
||||
import { saveTheme } from '../actions';
|
||||
|
||||
export function UserControls(): React.ReactElement<'div'> {
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector((state) => state[USER]);
|
||||
|
||||
const handleThemeChange = (newTheme: string) => {
|
||||
if (user.username) {
|
||||
dispatch(saveTheme(newTheme, user.username));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='user-controls'>
|
||||
<UserAutoUpdater getUserData={() => dispatch(getUserData())} />
|
||||
|
||||
<UserMenu user={user} />
|
||||
<UserMenu user={user} onThemeChange={handleThemeChange} />
|
||||
|
||||
<UserNotificationsMenu
|
||||
markAllNotificationsAsRead={() =>
|
||||
|
|
|
@ -106,3 +106,48 @@
|
|||
margin: 5px 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
|
||||
.appearance {
|
||||
color: var(--light-grey-7);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.appearance .help {
|
||||
padding-bottom: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toggle-button button {
|
||||
background: var(--black-3);
|
||||
border: 1px solid var(--light-grey-3);
|
||||
border-radius: 3px;
|
||||
color: var(--grey-6);
|
||||
font-size: 14px;
|
||||
font-weight: 100;
|
||||
padding: 4px;
|
||||
width: 78px;
|
||||
}
|
||||
|
||||
.toggle-button button:nth-child(2) {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.toggle-button button:hover {
|
||||
color: var(--light-grey-6);
|
||||
}
|
||||
|
||||
.toggle-button button.active {
|
||||
background: var(--dark-grey-1);
|
||||
border-color: var(--grey-3);
|
||||
color: var(--light-grey-7);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.toggle-button button .icon {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,11 @@ import { EntityView } from '~/context/EntityView';
|
|||
import { Location } from '~/context/Location';
|
||||
import * as Translator from '~/hooks/useTranslator';
|
||||
|
||||
import { findLocalizedById, MockLocalizationProvider } from '~/test/utils';
|
||||
import {
|
||||
findLocalizedById,
|
||||
MockLocalizationProvider,
|
||||
mockMatchMedia,
|
||||
} from '~/test/utils';
|
||||
|
||||
import { FileUpload } from './FileUpload';
|
||||
import { SignInOutForm } from './SignInOutForm';
|
||||
|
@ -14,6 +18,7 @@ import { UserMenu, UserMenuDialog } from './UserMenu';
|
|||
|
||||
describe('<UserMenuDialog>', () => {
|
||||
beforeAll(() => {
|
||||
mockMatchMedia();
|
||||
sinon.stub(Translator, 'useTranslator');
|
||||
});
|
||||
afterAll(() => {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Localized } from '@fluent/react';
|
|||
import React, { useContext, useRef, useState } from 'react';
|
||||
|
||||
import { EntityView } from '~/context/EntityView';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
import { Location } from '~/context/Location';
|
||||
import { useOnDiscard } from '~/utils';
|
||||
import { useTranslator } from '~/hooks/useTranslator';
|
||||
|
@ -14,15 +15,48 @@ import './UserMenu.css';
|
|||
|
||||
type Props = {
|
||||
user: UserState;
|
||||
onThemeChange: (theme: string) => void;
|
||||
};
|
||||
|
||||
type UserMenuProps = Props & {
|
||||
onDiscard: () => void;
|
||||
};
|
||||
|
||||
const ThemeButton = ({
|
||||
value,
|
||||
text,
|
||||
title,
|
||||
icon,
|
||||
user,
|
||||
onClick,
|
||||
}: {
|
||||
value: string;
|
||||
text: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
user: UserState;
|
||||
onClick: (theme: string) => void;
|
||||
}) => (
|
||||
<Localized
|
||||
id={`user-UserMenu--appearance-${value}`}
|
||||
elems={{ glyph: <i className={`icon ${icon}`} /> }}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
value={value}
|
||||
className={`${value} ${user.theme === value ? 'active' : ''}`}
|
||||
title={title}
|
||||
onClick={() => onClick(value)}
|
||||
>
|
||||
{`<glyph></glyph> ${text}`}
|
||||
</button>
|
||||
</Localized>
|
||||
);
|
||||
|
||||
export function UserMenuDialog({
|
||||
onDiscard,
|
||||
user,
|
||||
onThemeChange,
|
||||
}: UserMenuProps): React.ReactElement<'ul'> {
|
||||
const isTranslator = useTranslator();
|
||||
const { entity } = useContext(EntityView);
|
||||
|
@ -37,6 +71,13 @@ export function UserMenuDialog({
|
|||
const ref = useRef<HTMLUListElement>(null);
|
||||
useOnDiscard(ref, onDiscard);
|
||||
|
||||
const applyTheme = useTheme();
|
||||
|
||||
const handleThemeButtonClick = (selectedTheme: string) => {
|
||||
applyTheme(selectedTheme);
|
||||
onThemeChange(selectedTheme); // Save theme to the database
|
||||
};
|
||||
|
||||
return (
|
||||
<ul ref={ref} className='menu'>
|
||||
{user.isAuthenticated && (
|
||||
|
@ -49,6 +90,40 @@ export function UserMenuDialog({
|
|||
</a>
|
||||
</li>
|
||||
<li className='horizontal-separator'></li>
|
||||
|
||||
<div className='appearance'>
|
||||
<Localized id={`user-UserMenu--appearance-title`}>
|
||||
<p className='help'>Choose appearance</p>
|
||||
</Localized>
|
||||
<span className='toggle-button'>
|
||||
<ThemeButton
|
||||
value='dark'
|
||||
text='Dark'
|
||||
title='Use a dark theme'
|
||||
icon='far fa-moon'
|
||||
user={user}
|
||||
onClick={handleThemeButtonClick}
|
||||
/>
|
||||
<ThemeButton
|
||||
value='light'
|
||||
text='Light'
|
||||
title='Use a light theme'
|
||||
icon='fa fa-sun'
|
||||
user={user}
|
||||
onClick={handleThemeButtonClick}
|
||||
/>
|
||||
<ThemeButton
|
||||
value='system'
|
||||
text='System'
|
||||
title='Use a theme that matches your system settings'
|
||||
icon='fa fa-laptop'
|
||||
user={user}
|
||||
onClick={handleThemeButtonClick}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<li className='horizontal-separator'></li>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Action, UPDATE, UPDATE_SETTINGS } from './actions';
|
||||
import { Action, UPDATE, UPDATE_SETTINGS, UPDATE_THEME } from './actions';
|
||||
|
||||
// Name of this module.
|
||||
// Used as the key to store this module's reducer.
|
||||
|
@ -84,6 +84,7 @@ export type UserState = {
|
|||
readonly gravatarURLSmall: string;
|
||||
readonly gravatarURLBig: string;
|
||||
readonly notifications: Notifications;
|
||||
readonly theme: string;
|
||||
};
|
||||
|
||||
const initial: UserState = {
|
||||
|
@ -110,6 +111,7 @@ const initial: UserState = {
|
|||
notifications: [],
|
||||
unread_count: '0',
|
||||
},
|
||||
theme: 'dark',
|
||||
};
|
||||
|
||||
export function reducer(state: UserState = initial, action: Action): UserState {
|
||||
|
@ -140,12 +142,18 @@ export function reducer(state: UserState = initial, action: Action): UserState {
|
|||
notifications: [],
|
||||
unread_count: '0',
|
||||
},
|
||||
theme: action.data.theme ?? 'dark',
|
||||
};
|
||||
case UPDATE_SETTINGS:
|
||||
return {
|
||||
...state,
|
||||
settings: settings(state.settings, action),
|
||||
};
|
||||
case UPDATE_THEME:
|
||||
return {
|
||||
...state,
|
||||
theme: action.theme,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче