Skip to content

Commit

Permalink
Add Theme Switching Functionality to Translate View (#3002)
Browse files Browse the repository at this point in the history
Co-authored-by: Matjaž Horvat <matjaz.horvat@gmail.com>
  • Loading branch information
ayanaar and mathjazz authored Oct 26, 2023
1 parent a2d3d2e commit ee337e7
Show file tree
Hide file tree
Showing 11 changed files with 210 additions and 28 deletions.
1 change: 1 addition & 0 deletions pontoon/base/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)

Expand Down
8 changes: 8 additions & 0 deletions translate/public/locale/en-US/translate.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions translate/src/api/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
29 changes: 5 additions & 24 deletions translate/src/context/Theme.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
20 changes: 20 additions & 0 deletions translate/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -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`);
};
}
20 changes: 19 additions & 1 deletion translate/src/modules/user/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
fetchUserData,
markAllNotificationsAsRead,
updateUserSetting,
updateUserTheme,
} from '~/api/user';
import { NotificationMessage } from '~/context/Notification';
import {
Expand All @@ -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.
Expand All @@ -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':
Expand All @@ -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());
Expand Down
10 changes: 9 additions & 1 deletion translate/src/modules/user/components/UserControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={() =>
Expand Down
45 changes: 45 additions & 0 deletions translate/src/modules/user/components/UserMenu.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
7 changes: 6 additions & 1 deletion translate/src/modules/user/components/UserMenu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ 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';
import { UserMenu, UserMenuDialog } from './UserMenu';

describe('<UserMenuDialog>', () => {
beforeAll(() => {
mockMatchMedia();
sinon.stub(Translator, 'useTranslator');
});
afterAll(() => {
Expand Down
75 changes: 75 additions & 0 deletions translate/src/modules/user/components/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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 && (
Expand All @@ -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>
</>
)}

Expand Down
Loading

0 comments on commit ee337e7

Please sign in to comment.