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:
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export enum SettingsPath {
|
||||
ProfilePage = 'profile',
|
||||
Experience = 'profile/experience',
|
||||
WorkspaceMembersPage = 'workspace-members',
|
||||
Workspace = 'workspace',
|
||||
}
|
||||
|
||||
27
front/src/modules/ui/checkmark/components/Checkmark.tsx
Normal file
27
front/src/modules/ui/checkmark/components/Checkmark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
235
front/src/modules/ui/color-scheme/components/ColorSchemeCard.tsx
Normal file
235
front/src/modules/ui/color-scheme/components/ColorSchemeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>,
|
||||
),
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
53
front/src/modules/ui/themes/hooks/useColorScheme.ts
Normal file
53
front/src/modules/ui/themes/hooks/useColorScheme.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
front/src/modules/ui/themes/hooks/useSystemColorScheme.ts
Normal file
39
front/src/modules/ui/themes/hooks/useSystemColorScheme.ts
Normal 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;
|
||||
}
|
||||
@ -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};
|
||||
|
||||
@ -20,6 +20,11 @@ export const GET_CURRENT_USER = gql`
|
||||
inviteHash
|
||||
}
|
||||
}
|
||||
settings {
|
||||
id
|
||||
locale
|
||||
colorScheme
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user