mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1325 from omnivore-app/feat/integrations-page
This commit is contained in:
commit
32edce9771
12 changed files with 656 additions and 0 deletions
|
|
@ -21,6 +21,7 @@ export type HeaderDropdownAction =
|
|||
| 'navigate-to-profile'
|
||||
| 'navigate-to-subscriptions'
|
||||
| 'navigate-to-api'
|
||||
| 'navigate-to-integrations'
|
||||
| 'increaseFontSize'
|
||||
| 'decreaseFontSize'
|
||||
| 'logout'
|
||||
|
|
@ -87,6 +88,10 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
|
|||
onSelect={() => props.actionHandler('navigate-to-api')}
|
||||
title="API Keys"
|
||||
/>
|
||||
{/* <DropdownOption
|
||||
onSelect={() => props.actionHandler('navigate-to-integrations')}
|
||||
title="Integrations"
|
||||
/> */}
|
||||
<DropdownOption
|
||||
onSelect={() => window.Intercom('show')}
|
||||
title="Feedback"
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
|
|||
case 'navigate-to-api':
|
||||
router.push('/settings/api')
|
||||
break
|
||||
case 'navigate-to-integrations':
|
||||
router.push('/settings/integrations')
|
||||
break
|
||||
case 'logout':
|
||||
props.setShowLogoutConfirmation(true)
|
||||
break
|
||||
|
|
|
|||
128
packages/web/components/templates/integrations/Readwise.tsx
Normal file
128
packages/web/components/templates/integrations/Readwise.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { styled } from '@stitches/react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { FormInput } from '../../elements/FormElements'
|
||||
|
||||
import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation'
|
||||
import { Integration, useGetIntegrationsQuery } from '../../../lib/networking/queries/useGetIntegrationsQuery'
|
||||
import { Router, useRouter } from 'next/router'
|
||||
import { showSuccessToast } from '../../../lib/toastHelpers'
|
||||
|
||||
// Styles
|
||||
const Header = styled(Box, {
|
||||
color: '$utilityTextDefault',
|
||||
fontSize: 'x-large',
|
||||
margin: '20px',
|
||||
})
|
||||
|
||||
export function Readwise(): JSX.Element {
|
||||
const router = useRouter()
|
||||
const [token, setToken] = useState<string>('')
|
||||
const [errorMessage, setErrorMessage] =
|
||||
useState<string | undefined>(undefined)
|
||||
|
||||
const setReadwiseToken = useCallback(async () => {
|
||||
try {
|
||||
const result = await setIntegrationMutation({
|
||||
token,
|
||||
type: 'READWISE',
|
||||
enabled: true,
|
||||
})
|
||||
router.push(`/settings/integrations`)
|
||||
showSuccessToast('Your Readwise API token has been set.')
|
||||
} catch (err) {
|
||||
setErrorMessage('Error: ' + err)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<VStack
|
||||
distribution={'start'}
|
||||
alignment={'start'}
|
||||
css={{
|
||||
margin: '0 auto',
|
||||
width: '80%',
|
||||
height: '500px',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
alignment={'start'}
|
||||
distribution={'start'}
|
||||
css={{
|
||||
width: '100%',
|
||||
pb: '$2',
|
||||
borderBottom: '1px solid $utilityTextDefault',
|
||||
pr: '$1',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/static/icons/readwise.svg"
|
||||
alt="integration Image"
|
||||
width={75}
|
||||
height={75}
|
||||
/>
|
||||
<Header>Readwise</Header>
|
||||
</HStack>
|
||||
|
||||
<HStack
|
||||
css={{
|
||||
fontSize: '18px',
|
||||
color: '$utilityTextDefault',
|
||||
m: '20px',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>
|
||||
Enter your API key from Readwise below. You can get your token{' '}
|
||||
<Link
|
||||
style={{ color: '$utilityTextDefault' }}
|
||||
href="https://readwise.io/access_token"
|
||||
>
|
||||
here
|
||||
</Link>.
|
||||
</HStack>
|
||||
|
||||
<FormInput
|
||||
type="token"
|
||||
key="token"
|
||||
value={token}
|
||||
placeholder={'Readwise Token'}
|
||||
onChange={(e) => {
|
||||
e.preventDefault()
|
||||
setToken(e.target.value)
|
||||
}}
|
||||
disabled={false}
|
||||
hidden={false}
|
||||
required={true}
|
||||
css={{
|
||||
border: '1px solid $textNonessential',
|
||||
borderRadius: '8px',
|
||||
width: '80%',
|
||||
bg: 'transparent',
|
||||
fontSize: '16px',
|
||||
textIndent: '8px',
|
||||
margin: '20px',
|
||||
height: '38px',
|
||||
color: '$grayTextContrast',
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
boxShadow: '0px 0px 2px 2px rgba(255, 234, 159, 0.56)',
|
||||
},
|
||||
}}
|
||||
min={200}
|
||||
/>
|
||||
{errorMessage && <StyledText style="error">{errorMessage}</StyledText>}
|
||||
<Button
|
||||
style="ctaDarkYellow"
|
||||
css={{ my: '$2', margin: '20px' }}
|
||||
onClick={setReadwiseToken}
|
||||
>
|
||||
Set Token
|
||||
</Button>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
121
packages/web/components/templates/integrations/Webhooks.tsx
Normal file
121
packages/web/components/templates/integrations/Webhooks.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { styled } from '@stitches/react'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { Button } from '../../elements/Button'
|
||||
|
||||
import { Plus } from 'phosphor-react'
|
||||
import { useGetWebhooksQuery } from '../../../lib/networking/queries/useGetWebhooksQuery'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
// Styles
|
||||
const Header = styled(Box, {
|
||||
color: '$utilityTextDefault',
|
||||
fontSize: 'x-large',
|
||||
margin: '20px',
|
||||
})
|
||||
|
||||
interface Webhook {
|
||||
id?: string
|
||||
url: string
|
||||
eventTypes: string
|
||||
contentType?: string
|
||||
method?: string
|
||||
enabled?: string
|
||||
createdAt?: Date
|
||||
updatedAt?: Date
|
||||
}
|
||||
|
||||
export function Webhooks(): JSX.Element {
|
||||
|
||||
const { webhooks } = useGetWebhooksQuery()
|
||||
|
||||
const webhooksList = useMemo(() => {
|
||||
const webhooksList = new Map<string, Webhook>()
|
||||
webhooks.forEach((webhook) =>
|
||||
webhooksList.set(webhook.id, {
|
||||
url: webhook.url,
|
||||
eventTypes: webhook.eventTypes.join(', '),
|
||||
method: webhook.method,
|
||||
contentType: webhook.contentType,
|
||||
createdAt: webhook.createdAt,
|
||||
})
|
||||
)
|
||||
return webhooksList
|
||||
}, [webhooks])
|
||||
|
||||
console.log('webhooksList', webhooksList)
|
||||
return (
|
||||
<VStack
|
||||
distribution={'start'}
|
||||
css={{
|
||||
width: '80%',
|
||||
margin: '0 auto',
|
||||
height: '500px',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
alignment={'start'}
|
||||
distribution={'start'}
|
||||
css={{
|
||||
width: '100%',
|
||||
pb: '$2',
|
||||
borderBottom: '1px solid $utilityTextDefault',
|
||||
pr: '$1',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/static/icons/webhooks.svg"
|
||||
alt="integration Image"
|
||||
width={75}
|
||||
height={75}
|
||||
/>
|
||||
<Header>Webhooks</Header>
|
||||
|
||||
<HStack
|
||||
alignment={'center'}
|
||||
distribution={'end'}
|
||||
css={{
|
||||
width: '80%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
style="ctaDarkYellow"
|
||||
css={{
|
||||
py: '10px',
|
||||
px: '14px',
|
||||
}}
|
||||
>
|
||||
<Plus size={16} weight="bold" />
|
||||
<SpanBox css={{ pl: '10px', fontWeight: '600', fontSize: '16px' }}>
|
||||
Add New Webhook
|
||||
</SpanBox>
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack
|
||||
css={{
|
||||
fontSize: '16px',
|
||||
color: '$utilityTextDefault',
|
||||
m: '20px 0',
|
||||
}}
|
||||
>
|
||||
Use Webhooks to Ut enim ad minim veniam, quis nostrud exercitation.
|
||||
Tityre, tu patulae recubans sub tegmine fagi dolor. Etiam habebis sem
|
||||
dicantur magna mollis euismod.
|
||||
</HStack>
|
||||
|
||||
<HStack
|
||||
css={{
|
||||
border: '1px solid white',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}>
|
||||
|
||||
|
||||
</HStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { gql } from 'graphql-request'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
import { IntegrationType } from '../queries/useGetIntegrationsQuery'
|
||||
|
||||
export type SetIntegrationInput = {
|
||||
id?: string
|
||||
type: IntegrationType
|
||||
token: string,
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
type SetIntegrationResult = {
|
||||
setIntegration?: SetIntegrationData
|
||||
}
|
||||
|
||||
type SetIntegrationData = {
|
||||
setIntegration?: SetIntegrationSuccess
|
||||
errorCodes?: unknown[]
|
||||
}
|
||||
|
||||
type SetIntegrationSuccess = {
|
||||
integration: Integration
|
||||
}
|
||||
|
||||
type Integration = {
|
||||
id: string
|
||||
type: IntegrationType
|
||||
token: string
|
||||
enabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export async function setIntegrationMutation(
|
||||
input: SetIntegrationInput
|
||||
): Promise<Integration | undefined> {
|
||||
const mutation = gql`
|
||||
mutation SetIntegration(
|
||||
$input: SetIntegrationInput!
|
||||
) {
|
||||
setIntegration(input: $input) {
|
||||
... on SetIntegrationSuccess {
|
||||
integration {
|
||||
id
|
||||
type
|
||||
token
|
||||
enabled
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
... on SetIntegrationError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const data = await gqlFetcher(mutation, { input }) as SetIntegrationResult
|
||||
const output = data as any
|
||||
const error = data.setIntegration?.errorCodes?.find(() => true)
|
||||
if (error) {
|
||||
if (error === 'INVALID_TOKEN')
|
||||
throw 'Your token is invalid.'
|
||||
throw error
|
||||
}
|
||||
return output.setIntegration?.setIntegration?.integration
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { gql } from 'graphql-request'
|
||||
import useSWR from 'swr'
|
||||
import { publicGqlFetcher } from '../networkHelpers'
|
||||
|
||||
export interface Integration {
|
||||
id: string
|
||||
type: IntegrationType
|
||||
token: string
|
||||
enabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export type IntegrationType =
|
||||
| 'READWISE'
|
||||
|
||||
interface IntegrationsQueryResponse {
|
||||
isValidating: boolean
|
||||
integrations: Integration[]
|
||||
revalidate: () => void
|
||||
}
|
||||
|
||||
interface IntegrationsQueryResponseData {
|
||||
integrations: IntegrationsData
|
||||
}
|
||||
|
||||
interface IntegrationsData {
|
||||
integrations: unknown
|
||||
}
|
||||
|
||||
export function useGetIntegrationsQuery(): IntegrationsQueryResponse {
|
||||
const query = gql`
|
||||
query GetIntegrations {
|
||||
integrations {
|
||||
... on IntegrationsSuccess {
|
||||
integrations {
|
||||
id
|
||||
type
|
||||
token
|
||||
enabled
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
... on IntegrationsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const { data, mutate, error, isValidating } = useSWR(query, publicGqlFetcher)
|
||||
console.log('integrations data', data)
|
||||
|
||||
try {
|
||||
if (data) {
|
||||
const result = data as IntegrationsQueryResponseData
|
||||
const integrations = result.integrations.integrations as Integration[]
|
||||
return {
|
||||
isValidating,
|
||||
integrations,
|
||||
revalidate: () => {
|
||||
mutate()
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
return {
|
||||
isValidating: false,
|
||||
integrations: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
revalidate: () => {},
|
||||
}
|
||||
}
|
||||
193
packages/web/pages/settings/integrations.tsx
Normal file
193
packages/web/pages/settings/integrations.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { useEffect, useMemo, useState} from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { styled } from '@stitches/react'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { DownloadSimple, Eye, Link } from 'phosphor-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
SpanBox,
|
||||
VStack,
|
||||
} from '../../components/elements/LayoutPrimitives'
|
||||
import { PrimaryLayout } from '../../components/templates/PrimaryLayout'
|
||||
import { applyStoredTheme } from '../../lib/themeUpdater'
|
||||
import { Button } from '../../components/elements/Button'
|
||||
import { useGetIntegrationsQuery } from '../../lib/networking/queries/useGetIntegrationsQuery'
|
||||
import { useGetWebhooksQuery } from '../../lib/networking/queries/useGetWebhooksQuery'
|
||||
|
||||
// Styles
|
||||
const Header = styled(Box, {
|
||||
color: '$utilityTextDefault',
|
||||
fontSize: 'x-large',
|
||||
margin: '10px',
|
||||
})
|
||||
|
||||
const Subheader = styled(Box, {
|
||||
padding: '20px',
|
||||
color: '$utilityTextDefault',
|
||||
borderBottom: '1px solid $grayLine',
|
||||
margin: '0 auto',
|
||||
width: '80%',
|
||||
})
|
||||
|
||||
//interface
|
||||
interface Integrations {
|
||||
id: string
|
||||
}
|
||||
|
||||
type integrationsCard = {
|
||||
icon: string
|
||||
title: string
|
||||
subText?: string
|
||||
button: {
|
||||
text: string
|
||||
icon?: JSX.Element
|
||||
style: string
|
||||
action: () => void
|
||||
}
|
||||
}
|
||||
export default function Integrations(): JSX.Element {
|
||||
applyStoredTheme(false)
|
||||
const { integrations } = useGetIntegrationsQuery()
|
||||
const { webhooks } = useGetWebhooksQuery()
|
||||
|
||||
const [integrationsArray, setIntegrationsArray] = useState(
|
||||
Array<integrationsCard>()
|
||||
)
|
||||
const router = useRouter()
|
||||
|
||||
const readwiseConnected = useMemo(() => {
|
||||
return integrations.some((i) => i.type == 'READWISE')
|
||||
}, [integrations])
|
||||
|
||||
useEffect(() => {
|
||||
setIntegrationsArray([
|
||||
{
|
||||
icon: '/static/icons/logseq.svg',
|
||||
title: 'Logseq',
|
||||
subText: 'Logseq is an open-source knowledge base. Use the Omnivore Logseq plugin to sync articles, highlights, and notes to Logseq.',
|
||||
button: {
|
||||
text: `Install Logseq Plugin`,
|
||||
icon: <DownloadSimple size={16} weight={'bold'} />,
|
||||
style: 'ctaDarkYellow',
|
||||
action: () => {
|
||||
router.push(`https://github.com/omnivore-app/logseq-omnivore`)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: '/static/icons/readwise.svg',
|
||||
title: 'ReadWise',
|
||||
subText: 'Readwise makes it easy to revisit and learn from your ebook & article highlights. Use our Readwise integration to sync your highlights from Omnivore to Readwise.',
|
||||
button: {
|
||||
text: readwiseConnected ? 'Remove' : 'Connect to Readwise',
|
||||
icon: <Link size={16} weight={'bold'} />,
|
||||
style: readwiseConnected ? 'ctaWhite' : 'ctaDarkYellow',
|
||||
action: () => router.push("/settings/integrations/readwise")
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: '/static/icons/webhooks.svg',
|
||||
title: 'Webhooks',
|
||||
subText: `${webhooks.length} Webhooks`,
|
||||
button: {
|
||||
text: 'View Webhooks',
|
||||
icon: <Eye size={16} weight={'bold'} />,
|
||||
style: 'ctaWhite',
|
||||
action: () =>router.push("/settings/integrations/webhooks"),
|
||||
},
|
||||
},
|
||||
])
|
||||
}, [readwiseConnected, integrations, webhooks])
|
||||
|
||||
return (
|
||||
<PrimaryLayout pageTestId={'integrations'}>
|
||||
<Toaster
|
||||
containerStyle={{
|
||||
top: '5rem',
|
||||
}}
|
||||
/>
|
||||
<Header css={{ textAlign: 'center' }}>Integrations</Header>
|
||||
<Subheader>
|
||||
Connect with other applications can help enhance and streamline your
|
||||
experience with Omnivore, below are some useful apps to connect your
|
||||
Omnivore account to.
|
||||
</Subheader>
|
||||
<VStack
|
||||
distribution={'start'}
|
||||
css={{
|
||||
width: '80%',
|
||||
margin: '0 auto',
|
||||
height: '500px',
|
||||
}}
|
||||
>
|
||||
<Header>Applications</Header>
|
||||
|
||||
{integrationsArray.map((item) => {
|
||||
return (
|
||||
<HStack
|
||||
key={item.title}
|
||||
css={{
|
||||
width: '100%',
|
||||
borderRadius: '5px',
|
||||
backgroundColor: '$grayBg',
|
||||
margin: '10px 0',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'@smDown': {
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={item.icon}
|
||||
alt="integration Image"
|
||||
width={75}
|
||||
height={75}
|
||||
/>
|
||||
<Box
|
||||
css={{
|
||||
width: '60%',
|
||||
padding: '10px',
|
||||
color: '$utilityTextDefault',
|
||||
m: '10px',
|
||||
'h3, p': {
|
||||
margin: '0',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.subText}</p>
|
||||
</Box>
|
||||
<HStack>
|
||||
<Button
|
||||
style={
|
||||
item.button.style === 'ctaDarkYellow'
|
||||
? 'ctaDarkYellow'
|
||||
: 'ctaWhite'
|
||||
}
|
||||
css={{
|
||||
py: '10px',
|
||||
px: '14px',
|
||||
minWidth: '230px',
|
||||
}}
|
||||
onClick={item.button.action}
|
||||
>
|
||||
{item.button.icon}
|
||||
<SpanBox
|
||||
css={{ pl: '10px', fontWeight: '600', fontSize: '16px' }}
|
||||
>
|
||||
{item.button.text}
|
||||
</SpanBox>
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
})}
|
||||
</VStack>
|
||||
</PrimaryLayout>
|
||||
)
|
||||
}
|
||||
15
packages/web/pages/settings/integrations/readwise.tsx
Normal file
15
packages/web/pages/settings/integrations/readwise.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { PageMetaData } from '../../../components/patterns/PageMetaData'
|
||||
import { Readwise } from '../../../components/templates/integrations/Readwise'
|
||||
import { SettingsLayout } from '../../../components/templates/SettingsLayout'
|
||||
|
||||
export default function ReadwisePage(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<PageMetaData title="Readwise - Omnivore" path="/integrations/readwise" />
|
||||
<SettingsLayout>
|
||||
<Readwise />
|
||||
</SettingsLayout>
|
||||
<div data-testid="integrations-readwise-page-tag" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
15
packages/web/pages/settings/integrations/webhooks.tsx
Normal file
15
packages/web/pages/settings/integrations/webhooks.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { PageMetaData } from '../../../components/patterns/PageMetaData'
|
||||
import { Webhooks } from '../../../components/templates/integrations/Webhooks'
|
||||
import { SettingsLayout } from '../../../components/templates/SettingsLayout'
|
||||
|
||||
export default function ReadwisePage(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<PageMetaData title="Readwise - Omnivore" path="/integrations/webhooks" />
|
||||
<SettingsLayout>
|
||||
<Webhooks />
|
||||
</SettingsLayout>
|
||||
<div data-testid="integrations-readwise-page-tag" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
14
packages/web/public/static/icons/logseq.svg
Normal file
14
packages/web/public/static/icons/logseq.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
9
packages/web/public/static/icons/readwise.svg
Normal file
9
packages/web/public/static/icons/readwise.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 35 KiB |
9
packages/web/public/static/icons/webhooks.svg
Normal file
9
packages/web/public/static/icons/webhooks.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 364 KiB |
Loading…
Reference in a new issue