From 34e9ed16b5f538e5845f906dc17d2b92b579608d Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Sat, 30 Sep 2023 12:51:15 +0800 Subject: [PATCH] Add settings menu --- .../web/components/templates/SettingsMenu.tsx | 227 +++++++++++++++ .../mutations/updateUserMutation.ts | 37 +++ .../mutations/updateUserProfileMutation.ts | 40 +++ packages/web/pages/settings/account.tsx | 274 ++++++++++++++++++ 4 files changed, 578 insertions(+) create mode 100644 packages/web/components/templates/SettingsMenu.tsx create mode 100644 packages/web/lib/networking/mutations/updateUserMutation.ts create mode 100644 packages/web/lib/networking/mutations/updateUserProfileMutation.ts create mode 100644 packages/web/pages/settings/account.tsx diff --git a/packages/web/components/templates/SettingsMenu.tsx b/packages/web/components/templates/SettingsMenu.tsx new file mode 100644 index 000000000..333d39e88 --- /dev/null +++ b/packages/web/components/templates/SettingsMenu.tsx @@ -0,0 +1,227 @@ +import { ReactNode, useMemo } from 'react' +import { Box, HStack, SpanBox, VStack } from '../elements/LayoutPrimitives' +import { LIBRARY_LEFT_MENU_WIDTH } from './homeFeed/LibraryFilterMenu' +import { LogoBox } from '../elements/LogoBox' +import Link from 'next/link' +import { styled, theme } from '../tokens/stitches.config' +import { Button } from '../elements/Button' +import { ArrowSquareUpRight } from 'phosphor-react' +import { useRouter } from 'next/router' + +const HorizontalDivider = styled(SpanBox, { + width: '100%', + height: '1px', + my: '25px', + background: `${theme.colors.grayLine.toString()}`, +}) + +const StyledLink = styled(SpanBox, { + pl: '25px', + ml: '10px', + mb: '10px', + display: 'flex', + alignItems: 'center', + gap: '2px', + '&:hover': { + textDecoration: 'underline', + }, + + width: 'calc(100% - 10px)', + maxWidth: '100%', + height: '32px', + + fontSize: '14px', + fontWeight: 'regular', + fontFamily: '$display', + color: '$thLibraryMenuUnselected', + verticalAlign: 'middle', + borderRadius: '3px', + cursor: 'pointer', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}) + +export function SettingsMenu(): JSX.Element { + const section1 = [ + { name: 'Account', destination: '/settings/account' }, + { name: 'API Keys', destination: '/settings/api' }, + { name: 'Emails', destination: '/settings/emails' }, + { name: 'Feeds', destination: '/settings/feeds' }, + { name: 'Subscriptions', destination: '/settings/subscriptions' }, + { name: 'Labels', destination: '/settings/labels' }, + ] + + const section2 = [ + { name: 'Integrations', destination: '/settings/integrations' }, + { name: 'Install', destination: '/settings/installation' }, + ] + return ( + <> + + + + + + + {section1.map((item) => { + return + })} + + {section2.map((item) => { + return + })} + + + + + a': { + backgroundColor: 'transparent', + textDecoration: 'none', + }, + }} + > + + + Documentation + + + + + + + {/* This spacer pushes library content to the right of + the fixed left side menu. */} + + + ) +} + +type SettingsButtonProps = { + name: string + destination: string +} + +function SettingsButton(props: SettingsButtonProps): JSX.Element { + const router = useRouter() + const selected = useMemo(() => { + if (router && router.isReady) { + return router.asPath.endsWith(props.destination) + } + return false + }, [props, router]) + + return ( + + + {props.name} + + + ) +} diff --git a/packages/web/lib/networking/mutations/updateUserMutation.ts b/packages/web/lib/networking/mutations/updateUserMutation.ts new file mode 100644 index 000000000..c94a4c30c --- /dev/null +++ b/packages/web/lib/networking/mutations/updateUserMutation.ts @@ -0,0 +1,37 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' +import { State } from '../fragments/articleFragment' + +export type UpdateUserInput = { + name: string + bio: string +} + +export async function updateUserMutation( + input: UpdateUserInput +): Promise { + const mutation = gql` + mutation UpdateUser($input: UpdateUserInput!) { + updateUser(input: $input) { + ... on UpdateUserSuccess { + user { + name + } + } + ... on UpdateUserError { + errorCodes + } + } + } + ` + + try { + const data = await gqlFetcher(mutation, { + input, + }) + const output = data as any + return output.updateUser.user.name + } catch (err) { + return undefined + } +} diff --git a/packages/web/lib/networking/mutations/updateUserProfileMutation.ts b/packages/web/lib/networking/mutations/updateUserProfileMutation.ts new file mode 100644 index 000000000..3fd33d6d0 --- /dev/null +++ b/packages/web/lib/networking/mutations/updateUserProfileMutation.ts @@ -0,0 +1,40 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' +import { State } from '../fragments/articleFragment' + +export type UpdateUserProfileInput = { + userId: string + username: string +} + +export async function updateUserProfileMutation( + input: UpdateUserProfileInput +): Promise { + const mutation = gql` + mutation UpdateUserProfile($input: UpdateUserProfileInput!) { + updateUserProfile(input: $input) { + ... on UpdateUserProfileSuccess { + user { + profile { + username + } + } + } + ... on UpdateUserProfileError { + errorCodes + } + } + } + ` + + try { + const data = await gqlFetcher(mutation, { + input, + }) + const output = data as any + console.log('output: ', output) + return output.updateUserProfile.user.profile.username + } catch (err) { + return undefined + } +} diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx new file mode 100644 index 000000000..3869b8977 --- /dev/null +++ b/packages/web/pages/settings/account.tsx @@ -0,0 +1,274 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' +import { applyStoredTheme } from '../../lib/themeUpdater' +import { useGetApiKeysQuery } from '../../lib/networking/queries/useGetApiKeysQuery' +import { generateApiKeyMutation } from '../../lib/networking/mutations/generateApiKeyMutation' +import { revokeApiKeyMutation } from '../../lib/networking/mutations/revokeApiKeyMutation' + +import { + FormInput, + FormInputProps, +} from '../../components/elements/FormElements' +import { FormModal } from '../../components/patterns/FormModal' +import { ConfirmationModal } from '../../components/patterns/ConfirmationModal' +import { + EmptySettingsRow, + SettingsTable, + SettingsTableRow, +} from '../../components/templates/settings/SettingsTable' +import { StyledText } from '../../components/elements/StyledText' +import { formattedShortDate } from '../../lib/dateFormatting' +import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery' +import { SettingsLayout } from '../../components/templates/SettingsLayout' +import { Toaster } from 'react-hot-toast' +import { + Box, + SpanBox, + VStack, +} from '../../components/elements/LayoutPrimitives' +import { Button } from '../../components/elements/Button' +import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery' +import { updateUserMutation } from '../../lib/networking/mutations/updateUserMutation' +import { updateUserProfileMutation } from '../../lib/networking/mutations/updateUserProfileMutation' +import { styled } from '../../components/tokens/stitches.config' + +const StyledLabel = styled('label', { + fontWeight: 600, + fontSize: '16px', +}) + +export default function Account(): JSX.Element { + const { viewerData, isLoading } = useGetViewerQuery() + const [name, setName] = useState('') + const [username, setUsername] = useState('') + const [nameUpdating, setNameUpdating] = useState(false) + const [usernameUpdating, setUsernameUpdating] = useState(false) + + const [debouncedUsername, setDebouncedUsername] = useState('') + const { + isUsernameValid, + usernameErrorMessage, + isLoading: isUsernameValidationLoading, + } = useValidateUsernameQuery({ + username: debouncedUsername, + }) + + const usernameEdited = useMemo(() => { + return username !== viewerData?.me?.profile.username + }, [username, viewerData]) + + const usernameError = useMemo(() => { + return ( + usernameEdited && + username.length > 0 && + usernameErrorMessage && + !isUsernameValidationLoading + ) + }, [ + usernameEdited, + username, + usernameErrorMessage, + isUsernameValidationLoading, + ]) + + useEffect(() => { + if (viewerData?.me?.profile.username) { + setUsername(viewerData?.me?.profile.username) + } + }, [viewerData?.me?.profile.username]) + + useEffect(() => { + if (viewerData?.me?.name) { + setName(viewerData?.me?.name) + } + }, [viewerData?.me?.name]) + + const handleUsernameChange = useCallback( + (event: React.ChangeEvent): void => { + setUsername(event.target.value) + setTimeout(() => { + if (event.target.value) { + setDebouncedUsername(event.target.value) + } + }, 2000) + event.preventDefault() + }, + [] + ) + + const handleUpdateName = useCallback(() => { + setNameUpdating(true) + ;(async () => { + const updatedName = await updateUserMutation({ name, bio: '' }) + if (updatedName) { + setName(updatedName) + showSuccessToast('Name updated') + } else { + showErrorToast('Error updating name') + } + setNameUpdating(false) + })() + }, [name, nameUpdating, setName, setNameUpdating]) + + const handleUpdateUsername = useCallback(() => { + setUsernameUpdating(true) + + const userId = viewerData?.me?.id + if (!userId) { + showErrorToast('Error updating user info') + return + } + + ;(async () => { + const updatedUsername = await updateUserProfileMutation({ + userId, + username, + }) + if (updatedUsername) { + setUsername(updatedUsername) + setDebouncedUsername(updatedUsername) + showSuccessToast('Username updated') + } else { + showErrorToast('Error updating username') + } + setUsernameUpdating(false) + })() + }, [ + username, + usernameUpdating, + setUsername, + setUsernameUpdating, + viewerData?.me, + ]) + + applyStoredTheme(false) + + return ( + + + + + + + + Account Details + + + +
{ + handleUpdateName() + event.preventDefault() + }} + > + Name + { + setName(event.target.value) + event.preventDefault() + }} + /> + + Your name is displayed on your profile and is used when + communicating with you. + + + +
+ + + Username +
{ + handleUpdateUsername() + event.preventDefault() + }} + > + { + handleUsernameChange(event) + event.preventDefault() + }} + /> + + + {usernameError && !isUsernameValidationLoading && ( + <>{usernameErrorMessage} + )} + {usernameEdited && + !usernameError && + !isUsernameValidationLoading && <>Username is available.} + + + + Your username must be unique among all users. It can only + contain letters, numbers, and the underscore character. + + + * Changing your username may break some links from external + apps. + + + +
+
+
+
+ ) +}