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

@ -15,15 +15,15 @@ import { Index } from '~/pages/auth/Index';
import { PasswordLogin } from '~/pages/auth/PasswordLogin';
import { Verify } from '~/pages/auth/Verify';
import { Companies } from '~/pages/companies/Companies';
import { CompanyShow } from '~/pages/companies/CompanyShow';
import { Opportunities } from '~/pages/opportunities/Opportunities';
import { People } from '~/pages/people/People';
import { PersonShow } from '~/pages/people/PersonShow';
import { SettingsExperience } from '~/pages/settings/SettingsExperience';
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
import { SettingsWorksapce } from '~/pages/settings/SettingsWorkspace';
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
import { CompanyShow } from './pages/companies/CompanyShow';
import { PersonShow } from './pages/people/PersonShow';
import { SettingsWorksapce } from './pages/settings/SettingsWorkspace';
import { AppInternalHooks } from './sync-hooks/AppInternalHooks';
import { AppInternalHooks } from '~/sync-hooks/AppInternalHooks';
/**
* AuthRoutes is used to allow transitions between auth pages with framer-motion.
@ -100,6 +100,10 @@ export function App() {
path={SettingsPath.ProfilePage}
element={<SettingsProfile />}
/>
<Route
path={SettingsPath.Experience}
element={<SettingsExperience />}
/>
<Route
path={SettingsPath.WorkspaceMembersPage}
element={<SettingsWorkspaceMembers />}

View File

@ -75,6 +75,12 @@ export type ClientConfig = {
telemetry: Telemetry;
};
export enum ColorScheme {
Dark = 'Dark',
Light = 'Light',
System = 'System'
}
export type Comment = {
__typename?: 'Comment';
author: User;
@ -1277,6 +1283,17 @@ export type EnumActivityTypeFilter = {
notIn?: InputMaybe<Array<ActivityType>>;
};
export type EnumColorSchemeFieldUpdateOperationsInput = {
set?: InputMaybe<ColorScheme>;
};
export type EnumColorSchemeFilter = {
equals?: InputMaybe<ColorScheme>;
in?: InputMaybe<Array<ColorScheme>>;
not?: InputMaybe<NestedEnumColorSchemeFilter>;
notIn?: InputMaybe<Array<ColorScheme>>;
};
export type EnumCommentableTypeFieldUpdateOperationsInput = {
set?: InputMaybe<CommentableType>;
};
@ -1542,6 +1559,13 @@ export type NestedEnumActivityTypeFilter = {
notIn?: InputMaybe<Array<ActivityType>>;
};
export type NestedEnumColorSchemeFilter = {
equals?: InputMaybe<ColorScheme>;
in?: InputMaybe<Array<ColorScheme>>;
not?: InputMaybe<NestedEnumColorSchemeFilter>;
notIn?: InputMaybe<Array<ColorScheme>>;
};
export type NestedEnumCommentableTypeFilter = {
equals?: InputMaybe<CommentableType>;
in?: InputMaybe<Array<CommentableType>>;
@ -2843,9 +2867,10 @@ export type User = {
id: Scalars['ID'];
lastName?: Maybe<Scalars['String']>;
lastSeen?: Maybe<Scalars['DateTime']>;
locale: Scalars['String'];
metadata?: Maybe<Scalars['JSON']>;
phoneNumber?: Maybe<Scalars['String']>;
settings: UserSettings;
settingsId: Scalars['String'];
updatedAt: Scalars['DateTime'];
workspaceMember?: Maybe<WorkspaceMember>;
};
@ -2909,9 +2934,9 @@ export type UserCreateWithoutAssignedCommentThreadsInput = {
id?: InputMaybe<Scalars['String']>;
lastName?: InputMaybe<Scalars['String']>;
lastSeen?: InputMaybe<Scalars['DateTime']>;
locale: Scalars['String'];
metadata?: InputMaybe<Scalars['JSON']>;
phoneNumber?: InputMaybe<Scalars['String']>;
settings: UserSettingsCreateNestedOneWithoutUserInput;
updatedAt?: InputMaybe<Scalars['DateTime']>;
};
@ -2928,9 +2953,9 @@ export type UserCreateWithoutAuthoredCommentThreadsInput = {
id?: InputMaybe<Scalars['String']>;
lastName?: InputMaybe<Scalars['String']>;
lastSeen?: InputMaybe<Scalars['DateTime']>;
locale: Scalars['String'];
metadata?: InputMaybe<Scalars['JSON']>;
phoneNumber?: InputMaybe<Scalars['String']>;
settings: UserSettingsCreateNestedOneWithoutUserInput;
updatedAt?: InputMaybe<Scalars['DateTime']>;
};
@ -2947,9 +2972,9 @@ export type UserCreateWithoutCommentsInput = {
id?: InputMaybe<Scalars['String']>;
lastName?: InputMaybe<Scalars['String']>;
lastSeen?: InputMaybe<Scalars['DateTime']>;
locale: Scalars['String'];
metadata?: InputMaybe<Scalars['JSON']>;
phoneNumber?: InputMaybe<Scalars['String']>;
settings: UserSettingsCreateNestedOneWithoutUserInput;
updatedAt?: InputMaybe<Scalars['DateTime']>;
};
@ -2967,9 +2992,9 @@ export type UserCreateWithoutWorkspaceMemberInput = {
id?: InputMaybe<Scalars['String']>;
lastName?: InputMaybe<Scalars['String']>;
lastSeen?: InputMaybe<Scalars['DateTime']>;
locale: Scalars['String'];
metadata?: InputMaybe<Scalars['JSON']>;
phoneNumber?: InputMaybe<Scalars['String']>;
settings: UserSettingsCreateNestedOneWithoutUserInput;
updatedAt?: InputMaybe<Scalars['DateTime']>;
};
@ -2992,9 +3017,10 @@ export type UserOrderByWithRelationInput = {
id?: InputMaybe<SortOrder>;
lastName?: InputMaybe<SortOrder>;
lastSeen?: InputMaybe<SortOrder>;
locale?: InputMaybe<SortOrder>;
metadata?: InputMaybe<SortOrder>;
phoneNumber?: InputMaybe<SortOrder>;
settings?: InputMaybe<UserSettingsOrderByWithRelationInput>;
settingsId?: InputMaybe<SortOrder>;
updatedAt?: InputMaybe<SortOrder>;
};
@ -3014,13 +3040,93 @@ export enum UserScalarFieldEnum {
Id = 'id',
LastName = 'lastName',
LastSeen = 'lastSeen',
Locale = 'locale',
Metadata = 'metadata',
PasswordHash = 'passwordHash',
PhoneNumber = 'phoneNumber',
SettingsId = 'settingsId',
UpdatedAt = 'updatedAt'
}
export type UserSettings = {
__typename?: 'UserSettings';
colorScheme: ColorScheme;
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
locale: Scalars['String'];
updatedAt: Scalars['DateTime'];
user?: Maybe<User>;
};
export type UserSettingsCreateNestedOneWithoutUserInput = {
connect?: InputMaybe<UserSettingsWhereUniqueInput>;
connectOrCreate?: InputMaybe<UserSettingsCreateOrConnectWithoutUserInput>;
create?: InputMaybe<UserSettingsCreateWithoutUserInput>;
};
export type UserSettingsCreateOrConnectWithoutUserInput = {
create: UserSettingsCreateWithoutUserInput;
where: UserSettingsWhereUniqueInput;
};
export type UserSettingsCreateWithoutUserInput = {
colorScheme?: InputMaybe<ColorScheme>;
createdAt?: InputMaybe<Scalars['DateTime']>;
id?: InputMaybe<Scalars['String']>;
locale: Scalars['String'];
updatedAt?: InputMaybe<Scalars['DateTime']>;
};
export type UserSettingsOrderByWithRelationInput = {
colorScheme?: InputMaybe<SortOrder>;
createdAt?: InputMaybe<SortOrder>;
id?: InputMaybe<SortOrder>;
locale?: InputMaybe<SortOrder>;
updatedAt?: InputMaybe<SortOrder>;
user?: InputMaybe<UserOrderByWithRelationInput>;
};
export type UserSettingsRelationFilter = {
is?: InputMaybe<UserSettingsWhereInput>;
isNot?: InputMaybe<UserSettingsWhereInput>;
};
export type UserSettingsUpdateOneRequiredWithoutUserNestedInput = {
connect?: InputMaybe<UserSettingsWhereUniqueInput>;
connectOrCreate?: InputMaybe<UserSettingsCreateOrConnectWithoutUserInput>;
create?: InputMaybe<UserSettingsCreateWithoutUserInput>;
update?: InputMaybe<UserSettingsUpdateWithoutUserInput>;
upsert?: InputMaybe<UserSettingsUpsertWithoutUserInput>;
};
export type UserSettingsUpdateWithoutUserInput = {
colorScheme?: InputMaybe<EnumColorSchemeFieldUpdateOperationsInput>;
createdAt?: InputMaybe<DateTimeFieldUpdateOperationsInput>;
id?: InputMaybe<StringFieldUpdateOperationsInput>;
locale?: InputMaybe<StringFieldUpdateOperationsInput>;
updatedAt?: InputMaybe<DateTimeFieldUpdateOperationsInput>;
};
export type UserSettingsUpsertWithoutUserInput = {
create: UserSettingsCreateWithoutUserInput;
update: UserSettingsUpdateWithoutUserInput;
};
export type UserSettingsWhereInput = {
AND?: InputMaybe<Array<UserSettingsWhereInput>>;
NOT?: InputMaybe<Array<UserSettingsWhereInput>>;
OR?: InputMaybe<Array<UserSettingsWhereInput>>;
colorScheme?: InputMaybe<EnumColorSchemeFilter>;
createdAt?: InputMaybe<DateTimeFilter>;
id?: InputMaybe<StringFilter>;
locale?: InputMaybe<StringFilter>;
updatedAt?: InputMaybe<DateTimeFilter>;
user?: InputMaybe<UserRelationFilter>;
};
export type UserSettingsWhereUniqueInput = {
id?: InputMaybe<Scalars['String']>;
};
export type UserUpdateInput = {
assignedCommentThreads?: InputMaybe<CommentThreadUpdateManyWithoutAssigneeNestedInput>;
authoredCommentThreads?: InputMaybe<CommentThreadUpdateManyWithoutAuthorNestedInput>;
@ -3035,9 +3141,9 @@ export type UserUpdateInput = {
id?: InputMaybe<StringFieldUpdateOperationsInput>;
lastName?: InputMaybe<NullableStringFieldUpdateOperationsInput>;
lastSeen?: InputMaybe<NullableDateTimeFieldUpdateOperationsInput>;
locale?: InputMaybe<StringFieldUpdateOperationsInput>;
metadata?: InputMaybe<Scalars['JSON']>;
phoneNumber?: InputMaybe<NullableStringFieldUpdateOperationsInput>;
settings?: InputMaybe<UserSettingsUpdateOneRequiredWithoutUserNestedInput>;
updatedAt?: InputMaybe<DateTimeFieldUpdateOperationsInput>;
};
@ -3092,9 +3198,9 @@ export type UserUpdateWithoutAssignedCommentThreadsInput = {
id?: InputMaybe<StringFieldUpdateOperationsInput>;
lastName?: InputMaybe<NullableStringFieldUpdateOperationsInput>;
lastSeen?: InputMaybe<NullableDateTimeFieldUpdateOperationsInput>;
locale?: InputMaybe<StringFieldUpdateOperationsInput>;
metadata?: InputMaybe<Scalars['JSON']>;
phoneNumber?: InputMaybe<NullableStringFieldUpdateOperationsInput>;
settings?: InputMaybe<UserSettingsUpdateOneRequiredWithoutUserNestedInput>;
updatedAt?: InputMaybe<DateTimeFieldUpdateOperationsInput>;
};
@ -3111,9 +3217,9 @@ export type UserUpdateWithoutAuthoredCommentThreadsInput = {
id?: InputMaybe<StringFieldUpdateOperationsInput>;
lastName?: InputMaybe<NullableStringFieldUpdateOperationsInput>;
lastSeen?: InputMaybe<NullableDateTimeFieldUpdateOperationsInput>;
locale?: InputMaybe<StringFieldUpdateOperationsInput>;
metadata?: InputMaybe<Scalars['JSON']>;
phoneNumber?: InputMaybe<NullableStringFieldUpdateOperationsInput>;
settings?: InputMaybe<UserSettingsUpdateOneRequiredWithoutUserNestedInput>;
updatedAt?: InputMaybe<DateTimeFieldUpdateOperationsInput>;
};
@ -3130,9 +3236,9 @@ export type UserUpdateWithoutCommentsInput = {
id?: InputMaybe<StringFieldUpdateOperationsInput>;
lastName?: InputMaybe<NullableStringFieldUpdateOperationsInput>;
lastSeen?: InputMaybe<NullableDateTimeFieldUpdateOperationsInput>;
locale?: InputMaybe<StringFieldUpdateOperationsInput>;
metadata?: InputMaybe<Scalars['JSON']>;
phoneNumber?: InputMaybe<NullableStringFieldUpdateOperationsInput>;
settings?: InputMaybe<UserSettingsUpdateOneRequiredWithoutUserNestedInput>;
updatedAt?: InputMaybe<DateTimeFieldUpdateOperationsInput>;
};
@ -3150,9 +3256,9 @@ export type UserUpdateWithoutWorkspaceMemberInput = {
id?: InputMaybe<StringFieldUpdateOperationsInput>;
lastName?: InputMaybe<NullableStringFieldUpdateOperationsInput>;
lastSeen?: InputMaybe<NullableDateTimeFieldUpdateOperationsInput>;
locale?: InputMaybe<StringFieldUpdateOperationsInput>;
metadata?: InputMaybe<Scalars['JSON']>;
phoneNumber?: InputMaybe<NullableStringFieldUpdateOperationsInput>;
settings?: InputMaybe<UserSettingsUpdateOneRequiredWithoutUserNestedInput>;
updatedAt?: InputMaybe<DateTimeFieldUpdateOperationsInput>;
};
@ -3193,15 +3299,17 @@ export type UserWhereInput = {
id?: InputMaybe<StringFilter>;
lastName?: InputMaybe<StringNullableFilter>;
lastSeen?: InputMaybe<DateTimeNullableFilter>;
locale?: InputMaybe<StringFilter>;
metadata?: InputMaybe<JsonNullableFilter>;
phoneNumber?: InputMaybe<StringNullableFilter>;
settings?: InputMaybe<UserSettingsRelationFilter>;
settingsId?: InputMaybe<StringFilter>;
updatedAt?: InputMaybe<DateTimeFilter>;
};
export type UserWhereUniqueInput = {
email?: InputMaybe<Scalars['String']>;
id?: InputMaybe<Scalars['String']>;
settingsId?: InputMaybe<Scalars['String']>;
};
export type Verify = {
@ -3737,7 +3845,7 @@ export type SearchCompanyQuery = { __typename?: 'Query', searchResults: Array<{
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null } };
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, locale: string, colorScheme: ColorScheme } } };
export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>;
@ -3750,7 +3858,7 @@ export type UpdateUserMutationVariables = Exact<{
}>;
export type UpdateUserMutation = { __typename?: 'Mutation', updateUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null } };
export type UpdateUserMutation = { __typename?: 'Mutation', updateUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, locale: string, colorScheme: ColorScheme } } };
export type UploadProfilePictureMutationVariables = Exact<{
file: Scalars['Upload'];
@ -3764,7 +3872,7 @@ export type RemoveProfilePictureMutationVariables = Exact<{
}>;
export type RemoveProfilePictureMutation = { __typename?: 'Mutation', updateUser: { __typename?: 'User', id: string } };
export type RemoveProfilePictureMutation = { __typename?: 'Mutation', updateUser: { __typename?: 'User', id: string, avatarUrl?: string | null } };
export type GetWorkspaceMembersQueryVariables = Exact<{ [key: string]: never; }>;
@ -5662,6 +5770,11 @@ export const GetCurrentUserDocument = gql`
inviteHash
}
}
settings {
id
locale
colorScheme
}
}
}
`;
@ -5739,6 +5852,21 @@ export const UpdateUserDocument = gql`
firstName
lastName
avatarUrl
workspaceMember {
id
workspace {
id
domainName
displayName
logo
inviteHash
}
}
settings {
id
locale
colorScheme
}
}
}
`;
@ -5804,6 +5932,7 @@ export const RemoveProfilePictureDocument = gql`
mutation RemoveProfilePicture($where: UserWhereUniqueInput!) {
updateUser(data: {avatarUrl: {set: null}}, where: $where) {
id
avatarUrl
}
}
`;

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

View File

@ -0,0 +1,42 @@
import styled from '@emotion/styled';
import { ColorSchemePicker } from '@/ui/color-scheme/components/ColorSchemePicker';
import { NoTopBarContainer } from '@/ui/layout/components/NoTopBarContainer';
import { useColorScheme } from '@/ui/themes/hooks/useColorScheme';
import { MainSectionTitle } from '@/ui/title/components/MainSectionTitle';
import { SubSectionTitle } from '@/ui/title/components/SubSectionTitle';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(8)};
padding-bottom: ${({ theme }) => theme.spacing(10)};
width: 350px;
> * + * {
margin-top: ${({ theme }) => theme.spacing(8)};
}
`;
const StyledSectionContainer = styled.div`
> * + * {
margin-top: ${({ theme }) => theme.spacing(4)};
}
`;
export function SettingsExperience() {
const { colorScheme, setColorScheme } = useColorScheme();
return (
<NoTopBarContainer>
<div>
<StyledContainer>
<MainSectionTitle>Experience</MainSectionTitle>
<StyledSectionContainer>
<SubSectionTitle title="Appearance" />
<ColorSchemePicker value={colorScheme} onChange={setColorScheme} />
</StyledSectionContainer>
</StyledContainer>
</div>
</NoTopBarContainer>
);
}

View File

@ -1,22 +1,6 @@
import { User, Workspace, WorkspaceMember } from '~/generated/graphql';
import { ColorScheme, GetCurrentUserQuery } from '~/generated/graphql';
type MockedUser = Pick<
User,
| 'id'
| 'email'
| 'displayName'
| 'avatarUrl'
| '__typename'
| 'firstName'
| 'lastName'
> & {
workspaceMember: Pick<WorkspaceMember, 'id' | '__typename'> & {
workspace: Pick<
Workspace,
'id' | 'displayName' | 'domainName' | 'logo' | 'inviteHash' | '__typename'
>;
};
};
type MockedUser = GetCurrentUserQuery['currentUser'];
export const mockedUsersData: Array<MockedUser> = [
{
@ -39,6 +23,12 @@ export const mockedUsersData: Array<MockedUser> = [
logo: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII=',
},
},
settings: {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cde9y',
__typename: 'UserSettings',
locale: 'en',
colorScheme: ColorScheme.System,
},
},
{
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6c',
@ -59,5 +49,11 @@ export const mockedUsersData: Array<MockedUser> = [
logo: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII=',
},
},
settings: {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdt7a',
__typename: 'UserSettings',
locale: 'en',
colorScheme: ColorScheme.System,
},
},
];

View File

@ -13,7 +13,3 @@ export function formatToHumanReadableDate(date: Date | string) {
export const getLogoUrlFromDomainName = (domainName?: string): string => {
return `https://api.faviconkit.com/${domainName}/144`;
};
export const browserPrefersDarkMode = (): boolean => {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};

View File

@ -0,0 +1,37 @@
import { AtomEffect } from 'recoil';
import { cookieStorage } from '~/utils/cookie-storage';
export const localStorageEffect =
<T>(key: string): AtomEffect<T> =>
({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(key);
if (savedValue != null) {
setSelf(JSON.parse(savedValue));
}
onSet((newValue, _, isReset) => {
isReset
? localStorage.removeItem(key)
: localStorage.setItem(key, JSON.stringify(newValue));
});
};
export const cookieStorageEffect =
<T>(key: string): AtomEffect<T | 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));
});
};