mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
feat(ui): add syncable dark mode (#56243)
Co-authored-by: Ahmad Abdolsaheb <ahmad.abdolsaheb@gmail.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> Co-authored-by: Sem Bauke <semboot699@gmail.com>
This commit is contained in:
@@ -14,7 +14,7 @@ import MicrosoftLogo from '../assets/icons/microsoft-logo';
|
|||||||
import { createFlashMessage } from '../components/Flash/redux';
|
import { createFlashMessage } from '../components/Flash/redux';
|
||||||
import { Loader } from '../components/helpers';
|
import { Loader } from '../components/helpers';
|
||||||
import RedirectHome from '../components/redirect-home';
|
import RedirectHome from '../components/redirect-home';
|
||||||
import { Themes } from '../components/settings/theme';
|
import { LocalStorageThemes } from '../redux/types';
|
||||||
import { showCert, fetchProfileForUser } from '../redux/actions';
|
import { showCert, fetchProfileForUser } from '../redux/actions';
|
||||||
import {
|
import {
|
||||||
showCertSelector,
|
showCertSelector,
|
||||||
@@ -273,7 +273,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
|
|||||||
data-playwright-test-label='donation-form'
|
data-playwright-test-label='donation-form'
|
||||||
>
|
>
|
||||||
<MultiTierDonationForm
|
<MultiTierDonationForm
|
||||||
defaultTheme={Themes.Default}
|
defaultTheme={LocalStorageThemes.Light}
|
||||||
handleProcessing={handleProcessing}
|
handleProcessing={handleProcessing}
|
||||||
isMinimalForm={true}
|
isMinimalForm={true}
|
||||||
paymentContext={PaymentContext.Certificate}
|
paymentContext={PaymentContext.Certificate}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const loggedInProps = {
|
|||||||
navigate: navigate,
|
navigate: navigate,
|
||||||
showLoading: false,
|
showLoading: false,
|
||||||
submitNewAbout: jest.fn(),
|
submitNewAbout: jest.fn(),
|
||||||
toggleNightMode: jest.fn(),
|
toggleTheme: jest.fn(),
|
||||||
updateSocials: jest.fn(),
|
updateSocials: jest.fn(),
|
||||||
updateIsHonest: jest.fn(),
|
updateIsHonest: jest.fn(),
|
||||||
updatePortfolio: jest.fn(),
|
updatePortfolio: jest.fn(),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import DangerZone from '../components/settings/danger-zone';
|
|||||||
import Email from '../components/settings/email';
|
import Email from '../components/settings/email';
|
||||||
import Honesty from '../components/settings/honesty';
|
import Honesty from '../components/settings/honesty';
|
||||||
import Privacy from '../components/settings/privacy';
|
import Privacy from '../components/settings/privacy';
|
||||||
import { type ThemeProps, Themes } from '../components/settings/theme';
|
|
||||||
import UserToken from '../components/settings/user-token';
|
import UserToken from '../components/settings/user-token';
|
||||||
import ExamToken from '../components/settings/exam-token';
|
import ExamToken from '../components/settings/exam-token';
|
||||||
import { hardGoTo as navigate } from '../redux/actions';
|
import { hardGoTo as navigate } from '../redux/actions';
|
||||||
@@ -33,7 +32,6 @@ import {
|
|||||||
updateMyHonesty,
|
updateMyHonesty,
|
||||||
updateMyQuincyEmail,
|
updateMyQuincyEmail,
|
||||||
updateMySound,
|
updateMySound,
|
||||||
updateMyTheme,
|
|
||||||
updateMyKeyboardShortcuts,
|
updateMyKeyboardShortcuts,
|
||||||
verifyCert,
|
verifyCert,
|
||||||
resetMyEditorLayout
|
resetMyEditorLayout
|
||||||
@@ -42,7 +40,7 @@ import {
|
|||||||
const { apiLocation } = envData;
|
const { apiLocation } = envData;
|
||||||
|
|
||||||
// TODO: update types for actions
|
// TODO: update types for actions
|
||||||
type ShowSettingsProps = Pick<ThemeProps, 'toggleNightMode'> & {
|
type ShowSettingsProps = {
|
||||||
createFlashMessage: typeof createFlashMessage;
|
createFlashMessage: typeof createFlashMessage;
|
||||||
isSignedIn: boolean;
|
isSignedIn: boolean;
|
||||||
navigate: (location: string) => void;
|
navigate: (location: string) => void;
|
||||||
@@ -75,7 +73,6 @@ const mapDispatchToProps = {
|
|||||||
createFlashMessage,
|
createFlashMessage,
|
||||||
navigate,
|
navigate,
|
||||||
submitNewAbout,
|
submitNewAbout,
|
||||||
toggleNightMode: (theme: Themes) => updateMyTheme({ theme }),
|
|
||||||
toggleSoundMode: (sound: boolean) => updateMySound({ sound }),
|
toggleSoundMode: (sound: boolean) => updateMySound({ sound }),
|
||||||
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) =>
|
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) =>
|
||||||
updateMyKeyboardShortcuts({ keyboardShortcuts }),
|
updateMyKeyboardShortcuts({ keyboardShortcuts }),
|
||||||
@@ -91,7 +88,6 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
|
|||||||
const {
|
const {
|
||||||
createFlashMessage,
|
createFlashMessage,
|
||||||
isSignedIn,
|
isSignedIn,
|
||||||
toggleNightMode,
|
|
||||||
toggleSoundMode,
|
toggleSoundMode,
|
||||||
toggleKeyboardShortcuts,
|
toggleKeyboardShortcuts,
|
||||||
resetEditorLayout,
|
resetEditorLayout,
|
||||||
@@ -121,7 +117,6 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
|
|||||||
isHonest,
|
isHonest,
|
||||||
sendQuincyEmail,
|
sendQuincyEmail,
|
||||||
username,
|
username,
|
||||||
theme,
|
|
||||||
keyboardShortcuts
|
keyboardShortcuts
|
||||||
},
|
},
|
||||||
navigate,
|
navigate,
|
||||||
@@ -163,13 +158,11 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
|
|||||||
{t('settings.for', { username: username })}
|
{t('settings.for', { username: username })}
|
||||||
</h1>
|
</h1>
|
||||||
<MiscSettings
|
<MiscSettings
|
||||||
currentTheme={theme}
|
|
||||||
keyboardShortcuts={keyboardShortcuts}
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
sound={sound}
|
sound={sound}
|
||||||
editorLayout={editorLayout}
|
editorLayout={editorLayout}
|
||||||
resetEditorLayout={resetEditorLayout}
|
resetEditorLayout={resetEditorLayout}
|
||||||
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
|
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
|
||||||
toggleNightMode={toggleNightMode}
|
|
||||||
toggleSoundMode={toggleSoundMode}
|
toggleSoundMode={toggleSoundMode}
|
||||||
/>
|
/>
|
||||||
<Spacer size='m' />
|
<Spacer size='m' />
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import {
|
|||||||
isDonatingSelector,
|
isDonatingSelector,
|
||||||
signInLoadingSelector,
|
signInLoadingSelector,
|
||||||
donationFormStateSelector,
|
donationFormStateSelector,
|
||||||
completedChallengesSelector
|
completedChallengesSelector,
|
||||||
|
themeSelector
|
||||||
} from '../../redux/selectors';
|
} from '../../redux/selectors';
|
||||||
import { Themes } from '../settings/theme';
|
import { LocalStorageThemes, DonateFormState } from '../../redux/types';
|
||||||
import { DonateFormState } from '../../redux/types';
|
|
||||||
import type { CompletedChallenge } from '../../redux/prop-types';
|
import type { CompletedChallenge } from '../../redux/prop-types';
|
||||||
import { CENTS_IN_DOLLAR, formattedAmountLabel } from './utils';
|
import { CENTS_IN_DOLLAR, formattedAmountLabel } from './utils';
|
||||||
import DonateCompletion from './donate-completion';
|
import DonateCompletion from './donate-completion';
|
||||||
@@ -61,7 +61,7 @@ type PostCharge = (data: {
|
|||||||
|
|
||||||
type DonateFormProps = {
|
type DonateFormProps = {
|
||||||
postCharge: PostCharge;
|
postCharge: PostCharge;
|
||||||
defaultTheme?: Themes;
|
defaultTheme?: LocalStorageThemes;
|
||||||
email: string;
|
email: string;
|
||||||
handleProcessing?: () => void;
|
handleProcessing?: () => void;
|
||||||
editAmount?: () => void;
|
editAmount?: () => void;
|
||||||
@@ -72,10 +72,10 @@ type DonateFormProps = {
|
|||||||
isDonating: boolean;
|
isDonating: boolean;
|
||||||
showLoading: boolean;
|
showLoading: boolean;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
theme: Themes;
|
|
||||||
updateDonationFormState: (state: DonationApprovalData) => unknown;
|
updateDonationFormState: (state: DonationApprovalData) => unknown;
|
||||||
paymentContext: PaymentContext;
|
paymentContext: PaymentContext;
|
||||||
completedChallenges: CompletedChallenge[];
|
completedChallenges: CompletedChallenge[];
|
||||||
|
theme: LocalStorageThemes;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = createSelector(
|
const mapStateToProps = createSelector(
|
||||||
@@ -85,21 +85,23 @@ const mapStateToProps = createSelector(
|
|||||||
donationFormStateSelector,
|
donationFormStateSelector,
|
||||||
userSelector,
|
userSelector,
|
||||||
completedChallengesSelector,
|
completedChallengesSelector,
|
||||||
|
themeSelector,
|
||||||
(
|
(
|
||||||
showLoading: DonateFormProps['showLoading'],
|
showLoading: DonateFormProps['showLoading'],
|
||||||
isSignedIn: DonateFormProps['isSignedIn'],
|
isSignedIn: DonateFormProps['isSignedIn'],
|
||||||
isDonating: DonateFormProps['isDonating'],
|
isDonating: DonateFormProps['isDonating'],
|
||||||
donationFormState: DonateFormState,
|
donationFormState: DonateFormState,
|
||||||
{ email, theme }: { email: string; theme: Themes },
|
{ email }: { email: string },
|
||||||
completedChallenges: CompletedChallenge[]
|
completedChallenges: CompletedChallenge[],
|
||||||
|
theme: LocalStorageThemes
|
||||||
) => ({
|
) => ({
|
||||||
isSignedIn,
|
isSignedIn,
|
||||||
isDonating,
|
isDonating,
|
||||||
showLoading,
|
showLoading,
|
||||||
donationFormState,
|
donationFormState,
|
||||||
email,
|
email,
|
||||||
theme,
|
completedChallenges,
|
||||||
completedChallenges
|
theme
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
defaultTierAmount,
|
defaultTierAmount,
|
||||||
type DonationAmount
|
type DonationAmount
|
||||||
} from '../../../../shared/config/donation-settings'; // You can further extract these into separate components and import them
|
} from '../../../../shared/config/donation-settings'; // You can further extract these into separate components and import them
|
||||||
import { Themes } from '../settings/theme';
|
import { LocalStorageThemes } from '../../redux/types';
|
||||||
import { formattedAmountLabel, convertToTimeContributed } from './utils';
|
import { formattedAmountLabel, convertToTimeContributed } from './utils';
|
||||||
import DonateForm from './donate-form';
|
import DonateForm from './donate-form';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ type MultiTierDonationFormProps = {
|
|||||||
handleProcessing?: () => void;
|
handleProcessing?: () => void;
|
||||||
paymentContext: PaymentContext;
|
paymentContext: PaymentContext;
|
||||||
isMinimalForm?: boolean;
|
isMinimalForm?: boolean;
|
||||||
defaultTheme?: Themes;
|
defaultTheme?: LocalStorageThemes;
|
||||||
isAnimationEnabled?: boolean;
|
isAnimationEnabled?: boolean;
|
||||||
};
|
};
|
||||||
function SelectionTabs({
|
function SelectionTabs({
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '../../../../shared/config/donation-settings';
|
} from '../../../../shared/config/donation-settings';
|
||||||
import envData from '../../../config/env.json';
|
import envData from '../../../config/env.json';
|
||||||
import { userSelector, signInLoadingSelector } from '../../redux/selectors';
|
import { userSelector, signInLoadingSelector } from '../../redux/selectors';
|
||||||
import { Themes } from '../settings/theme';
|
import { LocalStorageThemes } from '../../redux/types';
|
||||||
import { DonationApprovalData, PostPayment } from './types';
|
import { DonationApprovalData, PostPayment } from './types';
|
||||||
import PayPalButtonScriptLoader from './paypal-button-script-loader';
|
import PayPalButtonScriptLoader from './paypal-button-script-loader';
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ type PaypalButtonProps = {
|
|||||||
isPaypalLoading: boolean;
|
isPaypalLoading: boolean;
|
||||||
t: (label: string) => string;
|
t: (label: string) => string;
|
||||||
ref?: Ref<PaypalButton>;
|
ref?: Ref<PaypalButton>;
|
||||||
theme: Themes;
|
theme: LocalStorageThemes;
|
||||||
isSubscription?: boolean;
|
isSubscription?: boolean;
|
||||||
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
|
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
|
||||||
isMinimalForm: boolean | undefined;
|
isMinimalForm: boolean | undefined;
|
||||||
@@ -91,7 +91,7 @@ class PaypalButton extends Component<PaypalButtonProps, PaypalButtonState> {
|
|||||||
const { duration, planId, amount } = this.state;
|
const { duration, planId, amount } = this.state;
|
||||||
const { t, theme, isPaypalLoading, isMinimalForm } = this.props;
|
const { t, theme, isPaypalLoading, isMinimalForm } = this.props;
|
||||||
const isSubscription = duration !== 'one-time';
|
const isSubscription = duration !== 'one-time';
|
||||||
const buttonColor = theme === Themes.Night ? 'white' : 'gold';
|
const buttonColor = theme === LocalStorageThemes.Dark ? 'white' : 'gold';
|
||||||
if (!paypalClientId) {
|
if (!paypalClientId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ import type {
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { PaymentProvider } from '../../../../shared/config/donation-settings';
|
import { PaymentProvider } from '../../../../shared/config/donation-settings';
|
||||||
import { Themes } from '../settings/theme';
|
import { LocalStorageThemes } from '../../redux/types';
|
||||||
import { DonationApprovalData, PostPayment } from './types';
|
import { DonationApprovalData, PostPayment } from './types';
|
||||||
|
|
||||||
interface FormPropTypes {
|
interface FormPropTypes {
|
||||||
onDonationStateChange: (donationState: DonationApprovalData) => void;
|
onDonationStateChange: (donationState: DonationApprovalData) => void;
|
||||||
postPayment: (arg0: PostPayment) => void;
|
postPayment: (arg0: PostPayment) => void;
|
||||||
t: (label: string) => string;
|
t: (label: string) => string;
|
||||||
theme: Themes;
|
theme: LocalStorageThemes;
|
||||||
processing: boolean;
|
processing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export default function StripeCardForm({
|
|||||||
base: {
|
base: {
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
fontFamily: 'Lato, sans-serif',
|
fontFamily: 'Lato, sans-serif',
|
||||||
color: `${theme === Themes.Night ? '#fff' : '#0a0a23'}`,
|
color: `${theme === LocalStorageThemes.Dark ? '#fff' : '#0a0a23'}`,
|
||||||
'::placeholder': {
|
'::placeholder': {
|
||||||
color: `#858591`
|
color: `#858591`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { PaymentRequest, Stripe } from '@stripe/stripe-js';
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Themes } from '../settings/theme';
|
import { LocalStorageThemes } from '../../redux/types';
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
DonationDuration
|
DonationDuration
|
||||||
@@ -17,7 +17,7 @@ import { DonationApprovalData, PostPayment } from './types';
|
|||||||
interface WrapperProps {
|
interface WrapperProps {
|
||||||
label: string;
|
label: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
theme: Themes;
|
theme: LocalStorageThemes;
|
||||||
duration: DonationDuration;
|
duration: DonationDuration;
|
||||||
postPayment: (arg0: PostPayment) => void;
|
postPayment: (arg0: PostPayment) => void;
|
||||||
onDonationStateChange: (donationState: DonationApprovalData) => void;
|
onDonationStateChange: (donationState: DonationApprovalData) => void;
|
||||||
@@ -145,7 +145,7 @@ const WalletsButton = ({
|
|||||||
style: {
|
style: {
|
||||||
paymentRequestButton: {
|
paymentRequestButton: {
|
||||||
type: 'default',
|
type: 'default',
|
||||||
theme: theme === Themes.Night ? 'light' : 'dark',
|
theme: theme === LocalStorageThemes.Light ? 'light' : 'dark',
|
||||||
height: '43px'
|
height: '43px'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import {
|
|||||||
FlashState,
|
FlashState,
|
||||||
State,
|
State,
|
||||||
FlashApp,
|
FlashApp,
|
||||||
FlashMessageArg
|
FlashMessageArg,
|
||||||
|
LocalStorageThemes
|
||||||
} from '../../../redux/types';
|
} from '../../../redux/types';
|
||||||
import { playTone } from '../../../utils/tone';
|
import { playTone } from '../../../utils/tone';
|
||||||
import { Themes } from '../../settings/theme';
|
|
||||||
import { FlashMessages } from './flash-messages';
|
import { FlashMessages } from './flash-messages';
|
||||||
|
|
||||||
export const flashMessageSelector = (state: State): FlashState['message'] =>
|
export const flashMessageSelector = (state: State): FlashState['message'] =>
|
||||||
@@ -33,7 +33,7 @@ export const createFlashMessage = (
|
|||||||
): ReducerPayload<FlashActionTypes.CreateFlashMessage> => {
|
): ReducerPayload<FlashActionTypes.CreateFlashMessage> => {
|
||||||
// Nightmode theme has special tones
|
// Nightmode theme has special tones
|
||||||
if (flash.variables?.theme) {
|
if (flash.variables?.theme) {
|
||||||
void playTone(flash.variables.theme as Themes);
|
void playTone(flash.variables.theme as LocalStorageThemes);
|
||||||
} else if (flash.message !== FlashMessages.None) {
|
} else if (flash.message !== FlashMessages.None) {
|
||||||
void playTone(flash.message);
|
void playTone(flash.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,31 +4,39 @@ import {
|
|||||||
faExternalLinkAlt
|
faExternalLinkAlt
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React, { Fragment } from 'react';
|
import React from 'react';
|
||||||
import { useTranslation, withTranslation } from 'react-i18next';
|
import { useTranslation, withTranslation } from 'react-i18next';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
import { radioLocation } from '../../../../config/env.json';
|
import { radioLocation } from '../../../../config/env.json';
|
||||||
import { openSignoutModal } from '../../../redux/actions';
|
import { openSignoutModal, toggleTheme } from '../../../redux/actions';
|
||||||
import { updateMyTheme } from '../../../redux/settings/actions';
|
|
||||||
import { Link } from '../../helpers';
|
import { Link } from '../../helpers';
|
||||||
import { type ThemeProps, Themes } from '../../settings/theme';
|
import { LocalStorageThemes } from '../../../redux/types';
|
||||||
|
import { themeSelector } from '../../../redux/selectors';
|
||||||
import { User } from '../../../redux/prop-types';
|
import { User } from '../../../redux/prop-types';
|
||||||
import SupporterBadge from '../../../assets/icons/supporter-badge';
|
import SupporterBadge from '../../../assets/icons/supporter-badge';
|
||||||
|
|
||||||
export interface NavLinksProps extends Pick<ThemeProps, 'toggleNightMode'> {
|
export interface NavLinksProps {
|
||||||
displayMenu: boolean;
|
displayMenu: boolean;
|
||||||
showMenu: () => void;
|
showMenu: () => void;
|
||||||
hideMenu: () => void;
|
hideMenu: () => void;
|
||||||
user?: User;
|
user?: User;
|
||||||
menuButtonRef: React.RefObject<HTMLButtonElement>;
|
menuButtonRef: React.RefObject<HTMLButtonElement>;
|
||||||
openSignoutModal: () => void;
|
openSignoutModal: () => void;
|
||||||
|
theme: LocalStorageThemes;
|
||||||
|
toggleTheme: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
toggleNightMode: (theme: Themes) => updateMyTheme({ theme }),
|
toggleTheme,
|
||||||
openSignoutModal
|
openSignoutModal
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = createSelector(
|
||||||
|
themeSelector,
|
||||||
|
(theme: LocalStorageThemes) => ({ theme })
|
||||||
|
);
|
||||||
|
|
||||||
interface DonateButtonProps {
|
interface DonateButtonProps {
|
||||||
isUserDonating: boolean | undefined;
|
isUserDonating: boolean | undefined;
|
||||||
handleMenuKeyDown: (event: React.KeyboardEvent<HTMLAnchorElement>) => void;
|
handleMenuKeyDown: (event: React.KeyboardEvent<HTMLAnchorElement>) => void;
|
||||||
@@ -65,29 +73,17 @@ const DonateButton = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTheme = (
|
|
||||||
currentTheme = Themes.Default,
|
|
||||||
toggleNightMode: typeof updateMyTheme
|
|
||||||
) => {
|
|
||||||
toggleNightMode(
|
|
||||||
currentTheme === Themes.Night ? Themes.Default : Themes.Night
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function NavLinks({
|
function NavLinks({
|
||||||
menuButtonRef,
|
menuButtonRef,
|
||||||
openSignoutModal,
|
openSignoutModal,
|
||||||
hideMenu,
|
hideMenu,
|
||||||
displayMenu,
|
displayMenu,
|
||||||
toggleNightMode,
|
user,
|
||||||
user
|
theme,
|
||||||
|
toggleTheme
|
||||||
}: NavLinksProps) {
|
}: NavLinksProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const { isDonating: isUserDonating, username: currentUserName } = user || {};
|
||||||
isDonating: isUserDonating,
|
|
||||||
username: currentUserName,
|
|
||||||
theme: currentUserTheme
|
|
||||||
} = user || {};
|
|
||||||
|
|
||||||
// the accessibility tree just needs a little more time to pick up the change.
|
// the accessibility tree just needs a little more time to pick up the change.
|
||||||
// This function allows us to set aria-expanded to false and then delay just a bit before setting focus on the button
|
// This function allows us to set aria-expanded to false and then delay just a bit before setting focus on the button
|
||||||
@@ -249,40 +245,16 @@ function NavLinks({
|
|||||||
</li>
|
</li>
|
||||||
<li className='nav-line' key='theme'>
|
<li className='nav-line' key='theme'>
|
||||||
<button
|
<button
|
||||||
{...(!currentUserName && { 'aria-describedby': 'theme-sign-in' })}
|
aria-pressed={theme === LocalStorageThemes.Dark}
|
||||||
aria-disabled={!currentUserName}
|
className={'nav-link nav-link-flex'}
|
||||||
aria-pressed={currentUserTheme === Themes.Night ? 'true' : 'false'}
|
onClick={toggleTheme}
|
||||||
className={
|
|
||||||
'nav-link nav-link-flex' +
|
|
||||||
(!currentUserName ? ' nav-link-header' : '')
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
if (currentUserName) {
|
|
||||||
toggleTheme(currentUserTheme, toggleNightMode);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={currentUserName ? handleMenuKeyDown : handleSignOutKeys}
|
onKeyDown={currentUserName ? handleMenuKeyDown : handleSignOutKeys}
|
||||||
>
|
>
|
||||||
{currentUserName ? (
|
<span>{t('settings.labels.night-mode')}</span>
|
||||||
<>
|
{theme === LocalStorageThemes.Dark ? (
|
||||||
<span>{t('settings.labels.night-mode')}</span>
|
<FontAwesomeIcon icon={faCheckSquare} />
|
||||||
{currentUserTheme === Themes.Night ? (
|
|
||||||
<FontAwesomeIcon icon={faCheckSquare} />
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon icon={faSquare} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<Fragment key='night-mode'>
|
<FontAwesomeIcon icon={faSquare} />
|
||||||
<span className='sr-only'>{t('settings.labels.night-mode')}</span>
|
|
||||||
<span
|
|
||||||
aria-hidden='true'
|
|
||||||
className='nav-link-dull'
|
|
||||||
id='theme-sign-in'
|
|
||||||
>
|
|
||||||
{t('misc.change-theme')}
|
|
||||||
</span>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -304,4 +276,7 @@ function NavLinks({
|
|||||||
|
|
||||||
NavLinks.displayName = 'NavLinks';
|
NavLinks.displayName = 'NavLinks';
|
||||||
|
|
||||||
export default connect(null, mapDispatchToProps)(withTranslation()(NavLinks));
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(withTranslation()(NavLinks));
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const SearchBarOptimized = Loadable(
|
|||||||
|
|
||||||
type UniversalNavProps = Omit<
|
type UniversalNavProps = Omit<
|
||||||
NavLinksProps,
|
NavLinksProps,
|
||||||
'toggleNightMode' | 'openSignoutModal'
|
'toggleTheme' | 'openSignoutModal'
|
||||||
> & {
|
> & {
|
||||||
fetchState: { pending: boolean };
|
fetchState: { pending: boolean };
|
||||||
searchBarRef?: React.RefObject<HTMLDivElement>;
|
searchBarRef?: React.RefObject<HTMLDivElement>;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import hackZeroSlashRegularURL from '../../../static/fonts/hack-zeroslash/Hack-Z
|
|||||||
import { isBrowser } from '../../../utils';
|
import { isBrowser } from '../../../utils';
|
||||||
import {
|
import {
|
||||||
fetchUser,
|
fetchUser,
|
||||||
|
initializeTheme,
|
||||||
onlineStatusChange,
|
onlineStatusChange,
|
||||||
serverStatusChange
|
serverStatusChange
|
||||||
} from '../../redux/actions';
|
} from '../../redux/actions';
|
||||||
@@ -32,7 +33,8 @@ import {
|
|||||||
userSelector,
|
userSelector,
|
||||||
isOnlineSelector,
|
isOnlineSelector,
|
||||||
isServerOnlineSelector,
|
isServerOnlineSelector,
|
||||||
userFetchStateSelector
|
userFetchStateSelector,
|
||||||
|
themeSelector
|
||||||
} from '../../redux/selectors';
|
} from '../../redux/selectors';
|
||||||
|
|
||||||
import { UserFetchState, User } from '../../redux/prop-types';
|
import { UserFetchState, User } from '../../redux/prop-types';
|
||||||
@@ -56,6 +58,7 @@ import './fonts.css';
|
|||||||
import './global.css';
|
import './global.css';
|
||||||
import './variables.css';
|
import './variables.css';
|
||||||
import './rtl-layout.css';
|
import './rtl-layout.css';
|
||||||
|
import { LocalStorageThemes } from '../../redux/types';
|
||||||
|
|
||||||
const mapStateToProps = createSelector(
|
const mapStateToProps = createSelector(
|
||||||
isSignedInSelector,
|
isSignedInSelector,
|
||||||
@@ -65,6 +68,7 @@ const mapStateToProps = createSelector(
|
|||||||
isServerOnlineSelector,
|
isServerOnlineSelector,
|
||||||
userFetchStateSelector,
|
userFetchStateSelector,
|
||||||
userSelector,
|
userSelector,
|
||||||
|
themeSelector,
|
||||||
(
|
(
|
||||||
isSignedIn,
|
isSignedIn,
|
||||||
examInProgress: boolean,
|
examInProgress: boolean,
|
||||||
@@ -72,7 +76,8 @@ const mapStateToProps = createSelector(
|
|||||||
isOnline: boolean,
|
isOnline: boolean,
|
||||||
isServerOnline: boolean,
|
isServerOnline: boolean,
|
||||||
fetchState: UserFetchState,
|
fetchState: UserFetchState,
|
||||||
user: User
|
user: User,
|
||||||
|
theme: LocalStorageThemes
|
||||||
) => ({
|
) => ({
|
||||||
isSignedIn,
|
isSignedIn,
|
||||||
examInProgress,
|
examInProgress,
|
||||||
@@ -81,8 +86,8 @@ const mapStateToProps = createSelector(
|
|||||||
isOnline,
|
isOnline,
|
||||||
isServerOnline,
|
isServerOnline,
|
||||||
fetchState,
|
fetchState,
|
||||||
theme: user.theme,
|
user,
|
||||||
user
|
theme
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,7 +99,8 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
|
|||||||
fetchUser,
|
fetchUser,
|
||||||
removeFlashMessage,
|
removeFlashMessage,
|
||||||
onlineStatusChange,
|
onlineStatusChange,
|
||||||
serverStatusChange
|
serverStatusChange,
|
||||||
|
initializeTheme
|
||||||
},
|
},
|
||||||
dispatch
|
dispatch
|
||||||
);
|
);
|
||||||
@@ -112,13 +118,6 @@ interface DefaultLayoutProps extends StateProps, DispatchProps {
|
|||||||
superBlock?: string;
|
superBlock?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSystemTheme = () =>
|
|
||||||
`${
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches === true
|
|
||||||
? 'dark-palette'
|
|
||||||
: 'light-palette'
|
|
||||||
}`;
|
|
||||||
|
|
||||||
function DefaultLayout({
|
function DefaultLayout({
|
||||||
children,
|
children,
|
||||||
hasMessage,
|
hasMessage,
|
||||||
@@ -137,7 +136,8 @@ function DefaultLayout({
|
|||||||
theme,
|
theme,
|
||||||
user,
|
user,
|
||||||
pathname,
|
pathname,
|
||||||
fetchUser
|
fetchUser,
|
||||||
|
initializeTheme
|
||||||
}: DefaultLayoutProps): JSX.Element {
|
}: DefaultLayoutProps): JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobileLayout = useMediaQuery({ maxWidth: MAX_MOBILE_WIDTH });
|
const isMobileLayout = useMediaQuery({ maxWidth: MAX_MOBILE_WIDTH });
|
||||||
@@ -148,6 +148,12 @@ function DefaultLayout({
|
|||||||
const isExSmallViewportHeight = useMediaQuery({
|
const isExSmallViewportHeight = useMediaQuery({
|
||||||
maxHeight: EX_SMALL_VIEWPORT_HEIGHT
|
maxHeight: EX_SMALL_VIEWPORT_HEIGHT
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initializeTheme();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// componentDidMount
|
// componentDidMount
|
||||||
if (!isSignedIn) {
|
if (!isSignedIn) {
|
||||||
@@ -170,8 +176,6 @@ function DefaultLayout({
|
|||||||
return typeof isOnline === 'boolean' ? onlineStatusChange(isOnline) : null;
|
return typeof isOnline === 'boolean' ? onlineStatusChange(isOnline) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useSystemTheme = fetchState.complete && isSignedIn === false;
|
|
||||||
|
|
||||||
const isJapanese = clientLocale === 'japanese';
|
const isJapanese = clientLocale === 'japanese';
|
||||||
|
|
||||||
if (fetchState.pending) {
|
if (fetchState.pending) {
|
||||||
@@ -183,9 +187,7 @@ function DefaultLayout({
|
|||||||
envData.environment === 'production' && <StagingWarningModal />}
|
envData.environment === 'production' && <StagingWarningModal />}
|
||||||
<Helmet
|
<Helmet
|
||||||
bodyAttributes={{
|
bodyAttributes={{
|
||||||
class: useSystemTheme
|
class: `${theme}-palette`
|
||||||
? getSystemTheme()
|
|
||||||
: `${String(theme) === 'night' ? 'dark' : 'light'}-palette`
|
|
||||||
}}
|
}}
|
||||||
meta={[
|
meta={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { createStore } from 'redux';
|
import { createStore } from 'redux';
|
||||||
import { Themes } from '../settings/theme';
|
import { UserThemes } from '../../redux/types';
|
||||||
import Profile from './profile';
|
import Profile from './profile';
|
||||||
|
|
||||||
jest.mock('../../analytics');
|
jest.mock('../../analytics');
|
||||||
@@ -46,7 +46,7 @@ const userProps = {
|
|||||||
sendQuincyEmail: true,
|
sendQuincyEmail: true,
|
||||||
sound: true,
|
sound: true,
|
||||||
keyboardShortcuts: false,
|
keyboardShortcuts: false,
|
||||||
theme: Themes.Default,
|
theme: UserThemes.Default,
|
||||||
twitter: 'string',
|
twitter: 'string',
|
||||||
username: 'string',
|
username: 'string',
|
||||||
website: 'string',
|
website: 'string',
|
||||||
|
|||||||
@@ -2,30 +2,26 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Spacer } from '@freecodecamp/ui';
|
import { Button, Spacer } from '@freecodecamp/ui';
|
||||||
import { FullWidthRow } from '../helpers';
|
import { FullWidthRow } from '../helpers';
|
||||||
import ThemeSettings, { ThemeProps } from '../../components/settings/theme';
|
|
||||||
import SoundSettings from '../../components/settings/sound';
|
import SoundSettings from '../../components/settings/sound';
|
||||||
import KeyboardShortcutsSettings from '../../components/settings/keyboard-shortcuts';
|
import KeyboardShortcutsSettings from '../../components/settings/keyboard-shortcuts';
|
||||||
import ScrollbarWidthSettings from '../../components/settings/scrollbar-width';
|
import ScrollbarWidthSettings from '../../components/settings/scrollbar-width';
|
||||||
|
|
||||||
type MiscSettingsProps = ThemeProps & {
|
type MiscSettingsProps = {
|
||||||
currentTheme: string;
|
|
||||||
keyboardShortcuts: boolean;
|
keyboardShortcuts: boolean;
|
||||||
sound: boolean;
|
sound: boolean;
|
||||||
editorLayout: boolean | null;
|
editorLayout: boolean | null;
|
||||||
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void;
|
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void;
|
||||||
toggleNightMode: () => void;
|
|
||||||
toggleSoundMode: (sound: boolean) => void;
|
toggleSoundMode: (sound: boolean) => void;
|
||||||
resetEditorLayout: () => void;
|
resetEditorLayout: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MiscSettings = ({
|
const MiscSettings = ({
|
||||||
currentTheme,
|
|
||||||
keyboardShortcuts,
|
keyboardShortcuts,
|
||||||
sound,
|
sound,
|
||||||
editorLayout,
|
editorLayout,
|
||||||
resetEditorLayout,
|
resetEditorLayout,
|
||||||
toggleKeyboardShortcuts,
|
toggleKeyboardShortcuts,
|
||||||
toggleNightMode,
|
|
||||||
toggleSoundMode
|
toggleSoundMode
|
||||||
}: MiscSettingsProps) => {
|
}: MiscSettingsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -34,10 +30,6 @@ const MiscSettings = ({
|
|||||||
<>
|
<>
|
||||||
<Spacer size='m' />
|
<Spacer size='m' />
|
||||||
<FullWidthRow>
|
<FullWidthRow>
|
||||||
<ThemeSettings
|
|
||||||
currentTheme={currentTheme}
|
|
||||||
toggleNightMode={toggleNightMode}
|
|
||||||
/>
|
|
||||||
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
|
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
|
||||||
<KeyboardShortcutsSettings
|
<KeyboardShortcutsSettings
|
||||||
keyboardShortcuts={keyboardShortcuts}
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { updateMyTheme } from '../../redux/settings/actions';
|
|
||||||
|
|
||||||
import ToggleButtonSetting from './toggle-button-setting';
|
|
||||||
|
|
||||||
export enum Themes {
|
|
||||||
Night = 'night',
|
|
||||||
Default = 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ThemeProps = {
|
|
||||||
currentTheme: Themes;
|
|
||||||
toggleNightMode: typeof updateMyTheme;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ThemeSettings({
|
|
||||||
currentTheme,
|
|
||||||
toggleNightMode
|
|
||||||
}: ThemeProps): JSX.Element {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToggleButtonSetting
|
|
||||||
action={t('settings.labels.night-mode')}
|
|
||||||
flag={currentTheme === Themes.Night}
|
|
||||||
flagName='currentTheme'
|
|
||||||
offLabel={t('buttons.off')}
|
|
||||||
onLabel={t('buttons.on')}
|
|
||||||
toggleFlag={() => {
|
|
||||||
toggleNightMode(
|
|
||||||
currentTheme === Themes.Night ? Themes.Default : Themes.Night
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ThemeSettings.displayName = 'ThemeSettings';
|
|
||||||
@@ -4,6 +4,9 @@ export const ns = 'app';
|
|||||||
|
|
||||||
export const actionTypes = createTypes(
|
export const actionTypes = createTypes(
|
||||||
[
|
[
|
||||||
|
'setTheme',
|
||||||
|
'initializeTheme',
|
||||||
|
'toggleTheme',
|
||||||
'appMount',
|
'appMount',
|
||||||
'hardGoTo',
|
'hardGoTo',
|
||||||
'allowBlockDonationRequests',
|
'allowBlockDonationRequests',
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export const fetchUser = createAction(actionTypes.fetchUser);
|
|||||||
export const fetchUserComplete = createAction(actionTypes.fetchUserComplete);
|
export const fetchUserComplete = createAction(actionTypes.fetchUserComplete);
|
||||||
export const fetchUserError = createAction(actionTypes.fetchUserError);
|
export const fetchUserError = createAction(actionTypes.fetchUserError);
|
||||||
|
|
||||||
|
export const toggleTheme = createAction(actionTypes.toggleTheme);
|
||||||
|
export const setTheme = createAction(actionTypes.setTheme);
|
||||||
|
export const initializeTheme = createAction(actionTypes.initializeTheme);
|
||||||
|
|
||||||
export const updateAllChallengesInfo = createAction(
|
export const updateAllChallengesInfo = createAction(
|
||||||
actionTypes.updateAllChallengesInfo
|
actionTypes.updateAllChallengesInfo
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { createUserTokenSaga } from './user-token-saga';
|
|||||||
import { createMsUsernameSaga } from './ms-username-saga';
|
import { createMsUsernameSaga } from './ms-username-saga';
|
||||||
import { createSurveySaga } from './survey-saga';
|
import { createSurveySaga } from './survey-saga';
|
||||||
import { createSessionCompletedChallengesSaga } from './session-completed-challenges';
|
import { createSessionCompletedChallengesSaga } from './session-completed-challenges';
|
||||||
|
import { createThemeSaga } from './theme-saga';
|
||||||
|
|
||||||
const defaultFetchState = {
|
const defaultFetchState = {
|
||||||
pending: true,
|
pending: true,
|
||||||
@@ -55,6 +56,7 @@ const initialState = {
|
|||||||
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
|
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
|
||||||
examInProgress: false,
|
examInProgress: false,
|
||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
|
theme: 'light',
|
||||||
showCert: {},
|
showCert: {},
|
||||||
showCertFetchState: {
|
showCertFetchState: {
|
||||||
...defaultFetchState
|
...defaultFetchState
|
||||||
@@ -87,6 +89,7 @@ export const epics = [hardGoToEpic, failedUpdatesEpic, updateCompleteEpic];
|
|||||||
|
|
||||||
export const sagas = [
|
export const sagas = [
|
||||||
...createAcceptTermsSaga(actionTypes),
|
...createAcceptTermsSaga(actionTypes),
|
||||||
|
...createThemeSaga(actionTypes),
|
||||||
...createAppMountSaga(actionTypes),
|
...createAppMountSaga(actionTypes),
|
||||||
...createDonationSaga(actionTypes),
|
...createDonationSaga(actionTypes),
|
||||||
...createFetchUserSaga(actionTypes),
|
...createFetchUserSaga(actionTypes),
|
||||||
@@ -253,6 +256,10 @@ export const reducer = handleActions(
|
|||||||
error: payload
|
error: payload
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
[actionTypes.setTheme]: (state, { payload: theme }) => ({
|
||||||
|
...state,
|
||||||
|
theme
|
||||||
|
}),
|
||||||
[actionTypes.onlineStatusChange]: (state, { payload: isOnline }) => ({
|
[actionTypes.onlineStatusChange]: (state, { payload: isOnline }) => ({
|
||||||
...state,
|
...state,
|
||||||
isOnline
|
isOnline
|
||||||
@@ -481,8 +488,6 @@ export const reducer = handleActions(
|
|||||||
payload ? spreadThePayloadOnUser(state, payload) : state,
|
payload ? spreadThePayloadOnUser(state, payload) : state,
|
||||||
[settingsTypes.updateMySoundComplete]: (state, { payload }) =>
|
[settingsTypes.updateMySoundComplete]: (state, { payload }) =>
|
||||||
payload ? spreadThePayloadOnUser(state, payload) : state,
|
payload ? spreadThePayloadOnUser(state, payload) : state,
|
||||||
[settingsTypes.updateMyThemeComplete]: (state, { payload }) =>
|
|
||||||
payload ? spreadThePayloadOnUser(state, payload) : state,
|
|
||||||
[settingsTypes.updateMyKeyboardShortcutsComplete]: (state, { payload }) =>
|
[settingsTypes.updateMyKeyboardShortcutsComplete]: (state, { payload }) =>
|
||||||
payload ? spreadThePayloadOnUser(state, payload) : state,
|
payload ? spreadThePayloadOnUser(state, payload) : state,
|
||||||
[settingsTypes.updateMyHonestyComplete]: (state, { payload }) =>
|
[settingsTypes.updateMyHonestyComplete]: (state, { payload }) =>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { HandlerProps } from 'react-reflex';
|
|||||||
import { SuperBlocks } from '../../../shared/config/curriculum';
|
import { SuperBlocks } from '../../../shared/config/curriculum';
|
||||||
import { BlockLayouts, BlockTypes } from '../../../shared/config/blocks';
|
import { BlockLayouts, BlockTypes } from '../../../shared/config/blocks';
|
||||||
import type { ChallengeFile, Ext } from '../../../shared/utils/polyvinyl';
|
import type { ChallengeFile, Ext } from '../../../shared/utils/polyvinyl';
|
||||||
import { Themes } from '../components/settings/theme';
|
|
||||||
import { type CertTitle } from '../../config/cert-and-project-map';
|
import { type CertTitle } from '../../config/cert-and-project-map';
|
||||||
|
import { UserThemes } from './types';
|
||||||
|
|
||||||
export type { ChallengeFile, Ext };
|
export type { ChallengeFile, Ext };
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ export type User = {
|
|||||||
savedChallenges: SavedChallenges;
|
savedChallenges: SavedChallenges;
|
||||||
sendQuincyEmail: boolean;
|
sendQuincyEmail: boolean;
|
||||||
sound: boolean;
|
sound: boolean;
|
||||||
theme: Themes;
|
theme: UserThemes;
|
||||||
keyboardShortcuts: boolean;
|
keyboardShortcuts: boolean;
|
||||||
twitter: string;
|
twitter: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ export const allChallengesInfoSelector = state =>
|
|||||||
export const userProfileFetchStateSelector = state =>
|
export const userProfileFetchStateSelector = state =>
|
||||||
state[MainApp].userProfileFetchState;
|
state[MainApp].userProfileFetchState;
|
||||||
export const usernameSelector = state => state[MainApp].appUsername;
|
export const usernameSelector = state => state[MainApp].appUsername;
|
||||||
|
export const themeSelector = state => state[MainApp].theme;
|
||||||
|
export const userThemeSelector = state => {
|
||||||
|
return userSelector(state).theme;
|
||||||
|
};
|
||||||
export const userSelector = state => {
|
export const userSelector = state => {
|
||||||
const username = usernameSelector(state);
|
const username = usernameSelector(state);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export const actionTypes = createTypes(
|
|||||||
...createAsyncTypes('updateMyEmail'),
|
...createAsyncTypes('updateMyEmail'),
|
||||||
...createAsyncTypes('updateMySocials'),
|
...createAsyncTypes('updateMySocials'),
|
||||||
...createAsyncTypes('updateMySound'),
|
...createAsyncTypes('updateMySound'),
|
||||||
...createAsyncTypes('updateMyTheme'),
|
|
||||||
...createAsyncTypes('updateMyKeyboardShortcuts'),
|
...createAsyncTypes('updateMyKeyboardShortcuts'),
|
||||||
...createAsyncTypes('updateMyHonesty'),
|
...createAsyncTypes('updateMyHonesty'),
|
||||||
...createAsyncTypes('updateMyQuincyEmail'),
|
...createAsyncTypes('updateMyQuincyEmail'),
|
||||||
|
|||||||
@@ -46,13 +46,6 @@ export const updateMySoundComplete = createAction(
|
|||||||
);
|
);
|
||||||
export const updateMySoundError = createAction(types.updateMySoundError);
|
export const updateMySoundError = createAction(types.updateMySoundError);
|
||||||
|
|
||||||
export const updateMyTheme = createAction(types.updateMyTheme);
|
|
||||||
export const updateMyThemeComplete = createAction(
|
|
||||||
types.updateMyThemeComplete,
|
|
||||||
checkForSuccessPayload
|
|
||||||
);
|
|
||||||
export const updateMyThemeError = createAction(types.updateMyThemeError);
|
|
||||||
|
|
||||||
export const updateMyKeyboardShortcuts = createAction(
|
export const updateMyKeyboardShortcuts = createAction(
|
||||||
types.updateMyKeyboardShortcuts
|
types.updateMyKeyboardShortcuts
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
putUpdateMyProfileUI,
|
putUpdateMyProfileUI,
|
||||||
putUpdateMyQuincyEmail,
|
putUpdateMyQuincyEmail,
|
||||||
putUpdateMySocials,
|
putUpdateMySocials,
|
||||||
putUpdateMyTheme,
|
|
||||||
putUpdateMyUsername,
|
putUpdateMyUsername,
|
||||||
putVerifyCert
|
putVerifyCert
|
||||||
} from '../../utils/ajax';
|
} from '../../utils/ajax';
|
||||||
@@ -50,8 +49,6 @@ import {
|
|||||||
updateMySocialsError,
|
updateMySocialsError,
|
||||||
updateMySoundComplete,
|
updateMySoundComplete,
|
||||||
updateMySoundError,
|
updateMySoundError,
|
||||||
updateMyThemeComplete,
|
|
||||||
updateMyThemeError,
|
|
||||||
validateUsernameComplete,
|
validateUsernameComplete,
|
||||||
validateUsernameError,
|
validateUsernameError,
|
||||||
verifyCertComplete,
|
verifyCertComplete,
|
||||||
@@ -132,16 +129,6 @@ function* resetMyEditorLayoutSaga() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function* updateMyThemeSaga({ payload: update }) {
|
|
||||||
try {
|
|
||||||
const { data } = yield call(putUpdateMyTheme, update);
|
|
||||||
yield put(updateMyThemeComplete({ ...data, payload: update }));
|
|
||||||
yield put(createFlashMessage({ ...data }));
|
|
||||||
} catch (e) {
|
|
||||||
yield put(updateMyThemeError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function* updateMyKeyboardShortcutsSaga({ payload: update }) {
|
function* updateMyKeyboardShortcutsSaga({ payload: update }) {
|
||||||
try {
|
try {
|
||||||
const { data } = yield call(putUpdateMyKeyboardShortcuts, update);
|
const { data } = yield call(putUpdateMyKeyboardShortcuts, update);
|
||||||
@@ -248,7 +235,6 @@ export function createSettingsSagas(types) {
|
|||||||
takeEvery(types.updateMyHonesty, updateMyHonestySaga),
|
takeEvery(types.updateMyHonesty, updateMyHonestySaga),
|
||||||
takeEvery(types.updateMySound, updateMySoundSaga),
|
takeEvery(types.updateMySound, updateMySoundSaga),
|
||||||
takeEvery(types.resetMyEditorLayout, resetMyEditorLayoutSaga),
|
takeEvery(types.resetMyEditorLayout, resetMyEditorLayoutSaga),
|
||||||
takeEvery(types.updateMyTheme, updateMyThemeSaga),
|
|
||||||
takeEvery(types.updateMyKeyboardShortcuts, updateMyKeyboardShortcutsSaga),
|
takeEvery(types.updateMyKeyboardShortcuts, updateMyKeyboardShortcutsSaga),
|
||||||
takeEvery(types.updateMyQuincyEmail, updateMyQuincyEmailSaga),
|
takeEvery(types.updateMyQuincyEmail, updateMyQuincyEmailSaga),
|
||||||
takeEvery(types.updateMyPortfolio, updateMyPortfolioSaga),
|
takeEvery(types.updateMyPortfolio, updateMyPortfolioSaga),
|
||||||
|
|||||||
45
client/src/redux/theme-saga.js
Normal file
45
client/src/redux/theme-saga.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { put, takeEvery, select, take } from 'redux-saga/effects';
|
||||||
|
import { createFlashMessage } from '../components/Flash/redux';
|
||||||
|
import { setTheme } from './actions';
|
||||||
|
import { actionTypes } from './action-types';
|
||||||
|
import { userThemeSelector } from './selectors';
|
||||||
|
|
||||||
|
function* toggleThemeSaga() {
|
||||||
|
const data = { type: 'success', message: 'flash.updated-themes' };
|
||||||
|
const currentTheme = localStorage.getItem('theme');
|
||||||
|
const invertedTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('theme', invertedTheme);
|
||||||
|
yield put(setTheme(invertedTheme));
|
||||||
|
yield put(createFlashMessage({ ...data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function* initializeThemeSaga() {
|
||||||
|
// Wait for the fetch userComplete action
|
||||||
|
yield take(actionTypes.fetchUserComplete);
|
||||||
|
|
||||||
|
const userTheme = yield select(userThemeSelector);
|
||||||
|
const localStorageTheme = localStorage.getItem('theme');
|
||||||
|
const isSysThemeDark = window.matchMedia(
|
||||||
|
'(prefers-color-scheme: dark)'
|
||||||
|
).matches;
|
||||||
|
|
||||||
|
let selectTheme = 'light';
|
||||||
|
|
||||||
|
if (localStorageTheme !== null) {
|
||||||
|
selectTheme = localStorageTheme === 'dark' ? 'dark' : 'light';
|
||||||
|
} else if (userTheme) {
|
||||||
|
selectTheme = userTheme === 'night' ? 'dark' : 'light';
|
||||||
|
} else if (isSysThemeDark) {
|
||||||
|
selectTheme = 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('theme', selectTheme);
|
||||||
|
yield put(setTheme(selectTheme));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createThemeSaga(types) {
|
||||||
|
return [
|
||||||
|
takeEvery(types.toggleTheme, toggleThemeSaga),
|
||||||
|
takeEvery(types.initializeTheme, initializeThemeSaga)
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -62,3 +62,13 @@ export interface UpdateCardState {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum LocalStorageThemes {
|
||||||
|
Light = 'light',
|
||||||
|
Dark = 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserThemes {
|
||||||
|
Night = 'night',
|
||||||
|
Default = 'default'
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ import store from 'store';
|
|||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Loader } from '../../../components/helpers';
|
import { Loader } from '../../../components/helpers';
|
||||||
import { Themes } from '../../../components/settings/theme';
|
import { LocalStorageThemes } from '../../../redux/types';
|
||||||
import { saveChallenge } from '../../../redux/actions';
|
import { saveChallenge } from '../../../redux/actions';
|
||||||
import {
|
import {
|
||||||
isDonationModalOpenSelector,
|
isDonationModalOpenSelector,
|
||||||
isSignedInSelector,
|
isSignedInSelector,
|
||||||
userSelector
|
themeSelector
|
||||||
} from '../../../redux/selectors';
|
} from '../../../redux/selectors';
|
||||||
import {
|
import {
|
||||||
ChallengeFiles,
|
ChallengeFiles,
|
||||||
@@ -101,7 +101,7 @@ export interface EditorProps {
|
|||||||
stopResetting: () => void;
|
stopResetting: () => void;
|
||||||
resetAttempts: () => void;
|
resetAttempts: () => void;
|
||||||
tests: Test[];
|
tests: Test[];
|
||||||
theme: Themes;
|
theme: LocalStorageThemes;
|
||||||
title: string;
|
title: string;
|
||||||
showProjectPreview: boolean;
|
showProjectPreview: boolean;
|
||||||
previewOpen: boolean;
|
previewOpen: boolean;
|
||||||
@@ -137,9 +137,9 @@ const mapStateToProps = createSelector(
|
|||||||
isProjectPreviewModalOpenSelector,
|
isProjectPreviewModalOpenSelector,
|
||||||
isResettingSelector,
|
isResettingSelector,
|
||||||
isSignedInSelector,
|
isSignedInSelector,
|
||||||
userSelector,
|
|
||||||
challengeTestsSelector,
|
challengeTestsSelector,
|
||||||
isChallengeCompletedSelector,
|
isChallengeCompletedSelector,
|
||||||
|
themeSelector,
|
||||||
(
|
(
|
||||||
attempts: number,
|
attempts: number,
|
||||||
canFocus: boolean,
|
canFocus: boolean,
|
||||||
@@ -148,9 +148,9 @@ const mapStateToProps = createSelector(
|
|||||||
previewOpen: boolean,
|
previewOpen: boolean,
|
||||||
isResetting: boolean,
|
isResetting: boolean,
|
||||||
isSignedIn: boolean,
|
isSignedIn: boolean,
|
||||||
{ theme }: { theme: Themes },
|
|
||||||
tests: [{ text: string; testString: string; message?: string }],
|
tests: [{ text: string; testString: string; message?: string }],
|
||||||
isChallengeCompleted: boolean
|
isChallengeCompleted: boolean,
|
||||||
|
theme: LocalStorageThemes
|
||||||
) => ({
|
) => ({
|
||||||
attempts,
|
attempts,
|
||||||
canFocus: open ? false : canFocus,
|
canFocus: open ? false : canFocus,
|
||||||
@@ -158,9 +158,9 @@ const mapStateToProps = createSelector(
|
|||||||
previewOpen,
|
previewOpen,
|
||||||
isResetting,
|
isResetting,
|
||||||
isSignedIn,
|
isSignedIn,
|
||||||
theme,
|
|
||||||
tests,
|
tests,
|
||||||
isChallengeCompleted
|
isChallengeCompleted,
|
||||||
|
theme
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1269,9 +1269,9 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
).matches;
|
).matches;
|
||||||
const editorSystemTheme = preferDarkScheme ? 'vs-dark-custom' : 'vs-custom';
|
const editorSystemTheme = preferDarkScheme ? 'vs-dark-custom' : 'vs-custom';
|
||||||
const editorTheme =
|
const editorTheme =
|
||||||
theme === Themes.Night
|
theme === LocalStorageThemes.Dark
|
||||||
? 'vs-dark-custom'
|
? 'vs-dark-custom'
|
||||||
: theme === Themes.Default
|
: theme === LocalStorageThemes.Light
|
||||||
? 'vs-custom'
|
? 'vs-custom'
|
||||||
: editorSystemTheme;
|
: editorSystemTheme;
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import React, { useRef } from 'react';
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { ReflexContainer, ReflexElement, ReflexSplitter } from 'react-reflex';
|
import { ReflexContainer, ReflexElement, ReflexSplitter } from 'react-reflex';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import {
|
import { isDonationModalOpenSelector } from '../../../redux/selectors';
|
||||||
userSelector,
|
|
||||||
isDonationModalOpenSelector
|
|
||||||
} from '../../../redux/selectors';
|
|
||||||
import {
|
import {
|
||||||
canFocusEditorSelector,
|
canFocusEditorSelector,
|
||||||
consoleOutputSelector,
|
consoleOutputSelector,
|
||||||
@@ -14,7 +11,6 @@ import {
|
|||||||
import { getTargetEditor } from '../utils/get-target-editor';
|
import { getTargetEditor } from '../utils/get-target-editor';
|
||||||
import './editor.css';
|
import './editor.css';
|
||||||
import { FileKey } from '../../../redux/prop-types';
|
import { FileKey } from '../../../redux/prop-types';
|
||||||
import { Themes } from '../../../components/settings/theme';
|
|
||||||
import Editor, { type EditorProps } from './editor';
|
import Editor, { type EditorProps } from './editor';
|
||||||
|
|
||||||
export type VisibleEditors = {
|
export type VisibleEditors = {
|
||||||
@@ -51,18 +47,15 @@ const mapStateToProps = createSelector(
|
|||||||
canFocusEditorSelector,
|
canFocusEditorSelector,
|
||||||
consoleOutputSelector,
|
consoleOutputSelector,
|
||||||
isDonationModalOpenSelector,
|
isDonationModalOpenSelector,
|
||||||
userSelector,
|
|
||||||
(
|
(
|
||||||
visibleEditors: VisibleEditors,
|
visibleEditors: VisibleEditors,
|
||||||
canFocus: boolean,
|
canFocus: boolean,
|
||||||
output: string[],
|
output: string[],
|
||||||
open,
|
open
|
||||||
{ theme }: { theme: Themes }
|
|
||||||
) => ({
|
) => ({
|
||||||
visibleEditors,
|
visibleEditors,
|
||||||
canFocus: open ? false : canFocus,
|
canFocus: open ? false : canFocus,
|
||||||
output,
|
output
|
||||||
theme
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -363,12 +363,6 @@ export function putUpdateMySocials(
|
|||||||
return put('/update-my-socials', update);
|
return put('/update-my-socials', update);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function putUpdateMyTheme(
|
|
||||||
update: Record<string, string>
|
|
||||||
): Promise<ResponseWithData<void>> {
|
|
||||||
return put('/update-my-theme', update);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function putUpdateMyKeyboardShortcuts(
|
export function putUpdateMyKeyboardShortcuts(
|
||||||
update: Record<string, string>
|
update: Record<string, string>
|
||||||
): Promise<ResponseWithData<void>> {
|
): Promise<ResponseWithData<void>> {
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import store from 'store';
|
import store from 'store';
|
||||||
import { FlashMessages } from '../../components/Flash/redux/flash-messages';
|
import { FlashMessages } from '../../components/Flash/redux/flash-messages';
|
||||||
import { Themes } from '../../components/settings/theme';
|
import { LocalStorageThemes } from '../../redux/types';
|
||||||
|
|
||||||
const TRY_AGAIN = 'https://campfire-mode.freecodecamp.org/try-again.mp3';
|
const TRY_AGAIN = 'https://campfire-mode.freecodecamp.org/try-again.mp3';
|
||||||
const CHAL_COMP = 'https://campfire-mode.freecodecamp.org/chal-comp.mp3';
|
const CHAL_COMP = 'https://campfire-mode.freecodecamp.org/chal-comp.mp3';
|
||||||
|
|
||||||
const toneUrls = {
|
const toneUrls = {
|
||||||
[Themes.Default]: 'https://campfire-mode.freecodecamp.org/day.mp3',
|
[LocalStorageThemes.Light]: 'https://campfire-mode.freecodecamp.org/day.mp3',
|
||||||
[Themes.Night]: 'https://campfire-mode.freecodecamp.org/night.mp3',
|
[LocalStorageThemes.Dark]: 'https://campfire-mode.freecodecamp.org/night.mp3',
|
||||||
donation: 'https://campfire-mode.freecodecamp.org/donate.mp3',
|
donation: 'https://campfire-mode.freecodecamp.org/donate.mp3',
|
||||||
'tests-completed': CHAL_COMP,
|
'tests-completed': CHAL_COMP,
|
||||||
'block-toggle': 'https://tonejs.github.io/audio/berklee/guitar_chord1.mp3',
|
'block-toggle': 'https://tonejs.github.io/audio/berklee/guitar_chord1.mp3',
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ test.describe('Editor theme if the system theme is dark', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('If the user is signed out', () => {
|
test.describe('If the user is signed out and has no local storage data', () => {
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
test('should be in dark mode', async ({ page }) => {
|
test('should be in dark mode', async ({ page }) => {
|
||||||
@@ -88,6 +88,27 @@ test.describe('Editor theme if the system theme is dark', () => {
|
|||||||
await expect(editor).toHaveClass(/vs-dark/);
|
await expect(editor).toHaveClass(/vs-dark/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('if the user is signed out and has a dark theme set in local storage', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('should be in dark mode', async ({ page }) => {
|
||||||
|
// go to the test page
|
||||||
|
await page.goto(testPage);
|
||||||
|
|
||||||
|
// set the dark theme in local storage
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
// reload the page to apply the local storage changes
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// check if the editor is in dark mode
|
||||||
|
const editor = page.locator("div[role='code'].monaco-editor");
|
||||||
|
await expect(editor).toHaveClass(/vs-dark/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Editor theme if the system theme is light', () => {
|
test.describe('Editor theme if the system theme is light', () => {
|
||||||
@@ -135,7 +156,7 @@ test.describe('Editor theme if the system theme is light', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('If the user is signed out', () => {
|
test.describe('If the user is signed out and has no local storage value', () => {
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
test('should be in light mode', async ({ page }) => {
|
test('should be in light mode', async ({ page }) => {
|
||||||
@@ -143,5 +164,26 @@ test.describe('Editor theme if the system theme is light', () => {
|
|||||||
const editor = page.locator("div[role='code'].monaco-editor");
|
const editor = page.locator("div[role='code'].monaco-editor");
|
||||||
await expect(editor).toHaveClass(/vs(?!\w)/);
|
await expect(editor).toHaveClass(/vs(?!\w)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('if the user is signed out and has a light theme set in local storage', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('should be in light mode', async ({ page }) => {
|
||||||
|
// go to the test page
|
||||||
|
await page.goto(testPage);
|
||||||
|
|
||||||
|
// set the light theme in local storage
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
// reload the page to apply the local storage changes
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// check if the editor is in light mode
|
||||||
|
const editor = page.locator("div[role='code'].monaco-editor");
|
||||||
|
await expect(editor).toHaveClass(/vs(?!\w)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -168,15 +168,6 @@ test.describe('Settings - Certified User', () => {
|
|||||||
name: translations.buttons['download-data']
|
name: translations.buttons['download-data']
|
||||||
});
|
});
|
||||||
await expect(downloadButton).toBeVisible();
|
await expect(downloadButton).toBeVisible();
|
||||||
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.getByRole('group', {
|
|
||||||
name: translations.settings.labels['night-mode'],
|
|
||||||
exact: true
|
|
||||||
})
|
|
||||||
.locator('p')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.locator('#legendsound')).toBeVisible();
|
await expect(page.locator('#legendsound')).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(translations.settings['sound-volume'])
|
page.getByText(translations.settings['sound-volume'])
|
||||||
|
|||||||
Reference in New Issue
Block a user