mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Add settings menu
This commit is contained in:
parent
16c000bad4
commit
34e9ed16b5
4 changed files with 578 additions and 0 deletions
227
packages/web/components/templates/SettingsMenu.tsx
Normal file
227
packages/web/components/templates/SettingsMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
packages/web/lib/networking/mutations/updateUserMutation.ts
Normal file
37
packages/web/lib/networking/mutations/updateUserMutation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
274
packages/web/pages/settings/account.tsx
Normal file
274
packages/web/pages/settings/account.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue