feat: implementing experience page (#718)

* feat: add color scheme toggle
* feat: colorScheme stored in UserSettings model
* feat: add stories
* fix: AnimatePresence exit not working

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Jérémy M
2023-07-18 19:47:27 +02:00
committed by GitHub
parent 4ec93d4b6a
commit 19e165fc05
137 changed files with 2792 additions and 75 deletions

View File

@ -1,26 +1,7 @@
import { atom, AtomEffect } from 'recoil';
import { atom } from 'recoil';
import { AuthTokenPair } from '~/generated/graphql';
import { cookieStorage } from '~/utils/cookie-storage';
const cookieStorageEffect =
(key: string): AtomEffect<AuthTokenPair | null> =>
({ setSelf, onSet }) => {
const savedValue = cookieStorage.getItem(key);
if (savedValue != null && JSON.parse(savedValue)['accessToken']) {
setSelf(JSON.parse(savedValue));
}
onSet((newValue, _, isReset) => {
if (!newValue) {
cookieStorage.removeItem(key);
return;
}
isReset
? cookieStorage.removeItem(key)
: cookieStorage.setItem(key, JSON.stringify(newValue));
});
};
import { cookieStorageEffect } from '~/utils/recoil-effects';
export const tokenPairState = atom<AuthTokenPair | null>({
key: 'tokenPairState',

View File

@ -42,7 +42,6 @@ export function SettingsNavbar() {
label="Experience"
to="/settings/profile/experience"
icon={<IconColorSwatch size={theme.icon.size.md} />}
soon={true}
active={
!!useMatch({
path: useResolvedPath('/settings/profile/experience').pathname,

View File

@ -1,5 +1,6 @@
export enum SettingsPath {
ProfilePage = 'profile',
Experience = 'profile/experience',
WorkspaceMembersPage = 'workspace-members',
Workspace = 'workspace',
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheck } from '@/ui/icon';
const StyledContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.color.blue};
border-radius: 50%;
display: flex;
height: 20px;
justify-content: center;
width: 20px;
`;
export type CheckmarkProps = React.ComponentPropsWithoutRef<'div'>;
export function Checkmark(props: CheckmarkProps) {
const theme = useTheme();
return (
<StyledContainer {...props}>
<IconCheck color={theme.color.gray0} size={14} />
</StyledContainer>
);
}

View File

@ -0,0 +1,235 @@
import React from 'react';
import styled from '@emotion/styled';
import {
AnimatePresence,
AnimationControls,
motion,
useAnimation,
} from 'framer-motion';
import { Checkmark } from '@/ui/checkmark/components/Checkmark';
import DarkNoise from '@/ui/themes/assets/dark-noise.png';
import LightNoise from '@/ui/themes/assets/light-noise.png';
import { grayScale } from '@/ui/themes/colors';
import { ColorScheme } from '~/generated/graphql';
const StyledColorSchemeBackground = styled.div<
Pick<ColorSchemeCardProps, 'variant'>
>`
align-items: flex-end;
background: ${({ variant }) => {
switch (variant) {
case 'dark':
return `url(${DarkNoise.toString()});`;
case 'light':
default:
return `url(${LightNoise.toString()});`;
}
}};
border: ${({ variant }) => {
switch (variant) {
case 'dark':
return `1px solid ${grayScale.gray65};`;
case 'light':
default:
return `1px solid ${grayScale.gray20};`;
}
}};
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 80px;
justify-content: flex-end;
overflow: hidden;
padding-left: ${({ theme }) => theme.spacing(6)};
padding-top: ${({ theme }) => theme.spacing(6)};
width: 120px;
`;
const StyledColorSchemeContent = styled(motion.div)<
Pick<ColorSchemeCardProps, 'variant'>
>`
background: ${({ theme, variant }) => {
switch (variant) {
case 'dark':
return grayScale.gray70;
case 'light':
return theme.color.gray0;
}
}};
border-left: ${({ variant }) => {
switch (variant) {
case 'dark':
return `1px solid ${grayScale.gray55};`;
case 'light':
default:
return `1px solid ${grayScale.gray20};`;
}
}};
border-radius: ${({ theme }) => theme.border.radius.md} 0px 0px 0px;
border-top: ${({ variant }) => {
switch (variant) {
case 'dark':
return `1px solid ${grayScale.gray55};`;
case 'light':
default:
return `1px solid ${grayScale.gray20};`;
}
}};
box-sizing: border-box;
color: ${({ variant }) => {
switch (variant) {
case 'dark':
return grayScale.gray30;
case 'light':
default:
return grayScale.gray55;
}
}};
display: flex;
flex: 1;
font-size: 20px;
height: 56px;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export type ColorSchemeSegmentProps = {
variant: `${Lowercase<ColorScheme.Dark | ColorScheme.Light>}`;
controls: AnimationControls;
} & React.ComponentPropsWithoutRef<'div'>;
function ColorSchemeSegment({
variant,
controls,
...rest
}: ColorSchemeSegmentProps) {
return (
<StyledColorSchemeBackground variant={variant} {...rest}>
<StyledColorSchemeContent animate={controls} variant={variant}>
Aa
</StyledColorSchemeContent>
</StyledColorSchemeBackground>
);
}
const StyledContainer = styled.div`
position: relative;
`;
const StyledMixedColorSchemeSegment = styled.div`
border-radius: ${({ theme }) => theme.border.radius.md};
cursor: pointer;
display: flex;
display: flex;
height: 80px;
overflow: hidden;
width: 120px;
`;
const StyledCheckmarkContainer = styled(motion.div)`
bottom: 0px;
padding: ${({ theme }) => theme.spacing(2)};
position: absolute;
right: 0px;
`;
export type ColorSchemeCardProps = {
variant: `${Lowercase<ColorScheme>}`;
selected?: boolean;
} & React.ComponentPropsWithoutRef<'div'>;
const checkmarkAnimationVariants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export function ColorSchemeCard({
variant,
selected,
...rest
}: ColorSchemeCardProps) {
const controls = useAnimation();
function handleMouseEnter() {
controls.start({
height: 61,
fontSize: '22px',
transition: { duration: 0.1 },
});
}
function handleMouseLeave() {
controls.start({
height: 56,
fontSize: '20px',
transition: { duration: 0.1 },
});
}
if (variant === 'system') {
return (
<StyledContainer>
<StyledMixedColorSchemeSegment
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...rest}
>
<ColorSchemeSegment
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
controls={controls}
variant="light"
/>
<ColorSchemeSegment
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
controls={controls}
variant="dark"
/>
</StyledMixedColorSchemeSegment>
<AnimatePresence>
{selected && (
<StyledCheckmarkContainer
key="system"
variants={checkmarkAnimationVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3 }}
>
<Checkmark />
</StyledCheckmarkContainer>
)}
</AnimatePresence>
</StyledContainer>
);
}
return (
<StyledContainer>
<ColorSchemeSegment
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
controls={controls}
variant={variant}
{...rest}
/>
<AnimatePresence>
{selected && (
<StyledCheckmarkContainer
key={variant}
variants={checkmarkAnimationVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3 }}
>
<Checkmark />
</StyledCheckmarkContainer>
)}
</AnimatePresence>
</StyledContainer>
);
}

View File

@ -0,0 +1,62 @@
import React from 'react';
import styled from '@emotion/styled';
import { ColorScheme } from '~/generated/graphql';
import { ColorSchemeCard } from './ColorSchemeCard';
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
> * + * {
margin-left: ${({ theme }) => theme.spacing(4)};
}
`;
const CardContainer = styled.div`
display: flex;
flex-direction: column;
`;
const Label = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export type ColorSchemePickerProps = {
value: ColorScheme;
onChange: (value: ColorScheme) => void;
};
export function ColorSchemePicker({ value, onChange }: ColorSchemePickerProps) {
return (
<StyledContainer>
<CardContainer>
<ColorSchemeCard
onClick={() => onChange(ColorScheme.Light)}
variant="light"
selected={value === ColorScheme.Light}
/>
<Label>Light</Label>
</CardContainer>
<CardContainer>
<ColorSchemeCard
onClick={() => onChange(ColorScheme.Dark)}
variant="dark"
selected={value === ColorScheme.Dark}
/>
<Label>Dark</Label>
</CardContainer>
<CardContainer>
<ColorSchemeCard
onClick={() => onChange(ColorScheme.System)}
variant="system"
selected={value === ColorScheme.System}
/>
<Label>System settings</Label>
</CardContainer>
</StyledContainer>
);
}

View File

@ -0,0 +1,42 @@
import styled from '@emotion/styled';
import type { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { ColorSchemeCard } from '../ColorSchemeCard';
const Container = styled.div`
display: flex;
flex-direction: row;
> * + * {
margin-left: ${({ theme }) => theme.spacing(4)};
}
`;
const meta: Meta<typeof ColorSchemeCard> = {
title: 'UI/ColorScheme/ColorSchemeCard',
component: ColorSchemeCard,
};
export default meta;
type Story = StoryObj<typeof ColorSchemeCard>;
export const Default: Story = {
render: getRenderWrapperForComponent(
<Container>
<ColorSchemeCard variant="light" selected={false} />
<ColorSchemeCard variant="dark" selected={false} />
<ColorSchemeCard variant="system" selected={false} />
</Container>,
),
};
export const Selected: Story = {
render: getRenderWrapperForComponent(
<Container>
<ColorSchemeCard variant="light" selected={true} />
<ColorSchemeCard variant="dark" selected={true} />
<ColorSchemeCard variant="system" selected={true} />
</Container>,
),
};

View File

@ -104,6 +104,7 @@ export const color = {
orange30: '#f09797',
orange20: '#fbc5c5',
orange10: '#fde4e4',
// TODO: Why color are not matching with design?
gray: grayScale.gray30,
gray80: grayScale.gray65,
gray70: grayScale.gray60,

View File

@ -1,14 +1,29 @@
import { ThemeProvider } from '@emotion/react';
import { darkTheme, lightTheme } from '@/ui/themes/themes';
import { browserPrefersDarkMode } from '~/utils';
import { ColorScheme } from '~/generated/graphql';
import { useColorScheme } from '../hooks/useColorScheme';
import { useSystemColorScheme } from '../hooks/useSystemColorScheme';
type OwnProps = {
children: JSX.Element;
};
export function AppThemeProvider({ children }: OwnProps) {
const selectedTheme = browserPrefersDarkMode() ? darkTheme : lightTheme;
const themes = {
[ColorScheme.Dark]: darkTheme,
[ColorScheme.Light]: lightTheme,
};
return <ThemeProvider theme={selectedTheme}>{children}</ThemeProvider>;
export function AppThemeProvider({ children }: OwnProps) {
const systemColorScheme = useSystemColorScheme();
const { colorScheme } = useColorScheme();
const theme =
themes[
colorScheme === ColorScheme.System ? systemColorScheme : colorScheme
];
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
}

View File

@ -0,0 +1,53 @@
import { useCallback, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { ColorScheme, useUpdateUserMutation } from '~/generated/graphql';
export function useColorScheme() {
const [currentUser] = useRecoilState(currentUserState);
const [updateUser] = useUpdateUserMutation();
const colorScheme = useMemo(() => {
if (!currentUser?.settings?.colorScheme) {
// Use system color scheme if user is not logged in or has no settings
return ColorScheme.System;
}
return currentUser.settings.colorScheme;
}, [currentUser?.settings?.colorScheme]);
const setColorScheme = useCallback(
async (value: ColorScheme) => {
try {
const result = await updateUser({
variables: {
where: {
id: currentUser?.id,
},
data: {
settings: {
update: {
colorScheme: {
set: value,
},
},
},
},
},
});
if (!result.data || result.errors) {
throw result.errors;
}
} catch (err) {}
},
[currentUser?.id, updateUser],
);
return {
colorScheme,
setColorScheme,
};
}

View File

@ -0,0 +1,39 @@
import { useEffect, useMemo, useState } from 'react';
import { ColorScheme } from '~/generated/graphql';
export type SystemColorScheme = ColorScheme.Light | ColorScheme.Dark;
export function useSystemColorScheme(): SystemColorScheme {
const mediaQuery = useMemo(
() => window.matchMedia('(prefers-color-scheme: dark)'),
[],
);
const [preferredColorScheme, setPreferredColorScheme] =
useState<SystemColorScheme>(
!window.matchMedia || !mediaQuery.matches
? ColorScheme.Light
: ColorScheme.Dark,
);
useEffect(() => {
if (!window.matchMedia) {
return;
}
function handleChange(event: MediaQueryListEvent): void {
setPreferredColorScheme(
event.matches ? ColorScheme.Dark : ColorScheme.Light,
);
}
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, [mediaQuery]);
return preferredColorScheme;
}

View File

@ -6,7 +6,7 @@ type OwnProps = {
};
const StyledMainSectionTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary};
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
line-height: ${({ theme }) => theme.text.lineHeight.lg};

View File

@ -20,6 +20,11 @@ export const GET_CURRENT_USER = gql`
inviteHash
}
}
settings {
id
locale
colorScheme
}
}
}
`;

View File

@ -9,6 +9,21 @@ export const UPDATE_USER = gql`
firstName
lastName
avatarUrl
workspaceMember {
id
workspace {
id
domainName
displayName
logo
inviteHash
}
}
settings {
id
locale
colorScheme
}
}
}
`;
@ -23,6 +38,7 @@ export const REMOVE_PROFILE_PICTURE = gql`
mutation RemoveProfilePicture($where: UserWhereUniqueInput!) {
updateUser(data: { avatarUrl: { set: null } }, where: $where) {
id
avatarUrl
}
}
`;