Add Theme Switching Functionality to Translate View (#3002)

Co-authored-by: Matjaž Horvat <matjaz.horvat@gmail.com>
This commit is contained in:
ayanaar 2023-10-26 11:13:28 -04:00 коммит произвёл GitHub
Родитель a2d3d2e28e
Коммит ee337e7f5c
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 210 добавлений и 28 удалений

Просмотреть файл

@ -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;
}