Add settings menu

This commit is contained in:
Jackson Harper 2023-09-30 12:51:15 +08:00
parent 16c000bad4
commit 34e9ed16b5
4 changed files with 578 additions and 0 deletions

View file

@ -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 (
<>
<Box
css={{
left: '0px',
top: '0px',
position: 'fixed',
bg: '$thLeftMenuBackground',
height: '100%',
width: LIBRARY_LEFT_MENU_WIDTH,
overflowY: 'auto',
overflowX: 'hidden',
'&::-webkit-scrollbar': {
display: 'none',
},
'@mdDown': {
visibility: 'hidden',
width: '100%',
transition: 'visibility 0s, top 150ms',
},
zIndex: 3,
}}
>
<Box
css={{
width: '100%',
px: '25px',
pb: '50px',
pt: '4.5px',
lineHeight: '1',
}}
>
<LogoBox />
</Box>
<VStack
css={{
gap: '10px',
width: '100%',
}}
distribution="start"
alignment="start"
>
{section1.map((item) => {
return <SettingsButton key={item.name} {...item} />
})}
<HorizontalDivider />
{section2.map((item) => {
return <SettingsButton key={item.name} {...item} />
})}
<HorizontalDivider />
<StyledLink>
<Button
style="link"
onClick={(event) => {
if (window.Intercom) {
window.Intercom('show')
}
event.preventDefault()
}}
>
Feedback
</Button>
</StyledLink>
<StyledLink
css={{
'> a': {
backgroundColor: 'transparent',
textDecoration: 'none',
},
}}
>
<a href="https://docs.omnivore.app" target="_blank">
<HStack
distribution="start"
alignment="center"
css={{
gap: '5px',
color: '$thLibraryMenuUnselected',
'&:hover': {
textDecoration: 'underline',
},
}}
>
Documentation
<ArrowSquareUpRight size={12} />
</HStack>
</a>
</StyledLink>
</VStack>
</Box>
{/* This spacer pushes library content to the right of
the fixed left side menu. */}
<Box
css={{
minWidth: LIBRARY_LEFT_MENU_WIDTH,
height: '100%',
bg: '$thBackground',
'@mdDown': {
display: 'none',
},
}}
></Box>
</>
)
}
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 (
<Link href={props.destination} passHref title={props.name}>
<SpanBox
css={{
mx: '10px',
pl: '25px',
display: 'flex',
alignItems: 'center',
gap: '2px',
width: 'calc(100% - 20px)',
maxWidth: '100%',
height: '32px',
fontSize: '14px',
fontWeight: 'regular',
fontFamily: '$display',
verticalAlign: 'middle',
borderRadius: '3px',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
backgroundColor: selected ? '$thLibrarySelectionColor' : 'unset',
color: selected
? '$thLibraryMenuSecondary'
: '$thLibraryMenuUnselected',
'&:hover': {
textDecoration: 'underline',
backgroundColor: selected
? '$thLibrarySelectionColor'
: '$thBackground4',
},
'&:active': {
backgroundColor: selected
? '$thLibrarySelectionColor'
: '$thBackground4',
},
}}
>
{props.name}
</SpanBox>
</Link>
)
}

View file

@ -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<string | undefined> {
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
}
}

View file

@ -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<string | undefined> {
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
}
}

View file

@ -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<HTMLInputElement>): 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 (
<SettingsLayout>
<Toaster
containerStyle={{
top: '5rem',
}}
/>
<VStack
css={{ width: '100%', height: '100%' }}
distribution="start"
alignment="center"
>
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
gap: '25px',
minWidth: '300px',
maxWidth: '865px',
}}
>
<Box>
<StyledText style="fixedHeadline" css={{ my: '6px' }}>
Account Details
</StyledText>
</Box>
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
bg: '$grayBg',
gap: '5px',
borderRadius: '5px',
}}
distribution="start"
alignment="start"
>
<form
onSubmit={(event) => {
handleUpdateName()
event.preventDefault()
}}
>
<StyledLabel>Name</StyledLabel>
<FormInput
type={'text'}
value={name}
tabIndex={1}
placeholder={'Name'}
disabled={nameUpdating}
onChange={(event) => {
setName(event.target.value)
event.preventDefault()
}}
/>
<StyledText style="footnote" css={{ mt: '10px', mb: '20px' }}>
Your name is displayed on your profile and is used when
communicating with you.
</StyledText>
<Button type="submit" style="ctaDarkYellow">
Update Name
</Button>
</form>
</VStack>
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
bg: '$grayBg',
gap: '5px',
borderRadius: '5px',
}}
>
<StyledLabel>Username</StyledLabel>
<form
onSubmit={(event) => {
handleUpdateUsername()
event.preventDefault()
}}
>
<FormInput
type={'text'}
placeholder={'Username'}
value={username}
disabled={usernameUpdating}
onChange={(event) => {
handleUsernameChange(event)
event.preventDefault()
}}
/>
<SpanBox>
<StyledText
style="caption"
css={{
m: 0,
minHeight: '20px',
color: usernameError ? '$error' : 'unset',
alignSelf: 'flex-start',
}}
>
{usernameError && !isUsernameValidationLoading && (
<>{usernameErrorMessage}</>
)}
{usernameEdited &&
!usernameError &&
!isUsernameValidationLoading && <>Username is available.</>}
</StyledText>
</SpanBox>
<StyledText style="footnote" css={{ mt: '10px', mb: '20px' }}>
Your username must be unique among all users. It can only
contain letters, numbers, and the underscore character.
</StyledText>
<StyledText style="footnote" css={{ mt: '10px', mb: '20px' }}>
* Changing your username may break some links from external
apps.
</StyledText>
<Button style="ctaDarkYellow">Update Username</Button>
</form>
</VStack>
</VStack>
</VStack>
</SettingsLayout>
)
}