mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge main
This commit is contained in:
commit
a58864fe2c
31 changed files with 890 additions and 1097 deletions
File diff suppressed because one or more lines are too long
|
|
@ -159,8 +159,8 @@ public struct GridCard: View {
|
|||
// .onTapGesture { tapHandler() }
|
||||
}
|
||||
|
||||
if let status = item.serverSyncStatus, status != ServerSyncStatus.isNSync.rawValue {
|
||||
SyncStatusIcon(status: ServerSyncStatus(rawValue: Int(status)) ?? ServerSyncStatus.isNSync)
|
||||
if item.serverSyncStatus != ServerSyncStatus.isNSync.rawValue {
|
||||
SyncStatusIcon(status: ServerSyncStatus(rawValue: Int(item.serverSyncStatus)) ?? ServerSyncStatus.isNSync)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 0)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,8 @@ public struct FeedCard: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
if let recs = Recommendation.notViewers(viewer: viewer, item.recommendations), recs.count > 0 {
|
||||
let recs = Recommendation.notViewers(viewer: viewer, item.recommendations)
|
||||
if recs.count > 0 {
|
||||
let byStr = Recommendation.byline(recs)
|
||||
let inStr = Recommendation.groupsLine(recs)
|
||||
HStack {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -31,6 +31,7 @@ import { Merge } from '../../util'
|
|||
import { analytics } from '../../utils/analytics'
|
||||
import { enqueueRssFeedFetch } from '../../utils/createTask'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { Brackets } from 'typeorm'
|
||||
|
||||
type PartialSubscription = Omit<Subscription, 'newsletterEmail'>
|
||||
|
||||
|
|
@ -47,61 +48,71 @@ export const subscriptionsResolver = authorized<
|
|||
SubscriptionsSuccessPartial,
|
||||
SubscriptionsError,
|
||||
QuerySubscriptionsArgs
|
||||
>(
|
||||
async (
|
||||
_obj,
|
||||
{ sort, type = SubscriptionType.Newsletter }, // default to newsletter
|
||||
{ claims: { uid }, log }
|
||||
) => {
|
||||
log.info('subscriptionsResolver')
|
||||
>(async (_obj, { sort, type }, { claims: { uid }, log }) => {
|
||||
log.info('subscriptionsResolver')
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'subscriptions',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const sortBy =
|
||||
sort?.by === SortBy.UpdatedTime ? 'lastFetchedAt' : 'createdAt'
|
||||
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [SubscriptionsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const queryBuilder = getRepository(Subscription)
|
||||
.createQueryBuilder('subscription')
|
||||
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
|
||||
.where({
|
||||
user: { id: uid },
|
||||
type,
|
||||
})
|
||||
|
||||
// only return active subscriptions for newsletter
|
||||
if (type === SubscriptionType.Newsletter) {
|
||||
queryBuilder.andWhere({ status: SubscriptionStatus.Active })
|
||||
}
|
||||
|
||||
const subscriptions = await queryBuilder
|
||||
.orderBy('subscription.' + sortBy, sortOrder)
|
||||
.getMany()
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'subscriptions',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const sortBy =
|
||||
sort?.by === SortBy.UpdatedTime ? 'lastFetchedAt' : 'createdAt'
|
||||
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
subscriptions,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
return {
|
||||
errorCodes: [SubscriptionsErrorCode.BadRequest],
|
||||
errorCodes: [SubscriptionsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const queryBuilder = getRepository(Subscription)
|
||||
.createQueryBuilder('subscription')
|
||||
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
|
||||
.where({
|
||||
user: { id: uid },
|
||||
})
|
||||
|
||||
if (type && type == SubscriptionType.Newsletter) {
|
||||
queryBuilder.andWhere({
|
||||
type,
|
||||
status: SubscriptionStatus.Active,
|
||||
})
|
||||
} else if (type && type == SubscriptionType.Rss) {
|
||||
queryBuilder.andWhere({
|
||||
type,
|
||||
})
|
||||
} else {
|
||||
queryBuilder.andWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where({
|
||||
type: SubscriptionType.Newsletter,
|
||||
status: SubscriptionStatus.Active,
|
||||
}).orWhere({
|
||||
type: SubscriptionType.Rss,
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const subscriptions = await queryBuilder
|
||||
.orderBy('subscription.' + sortBy, sortOrder)
|
||||
.getMany()
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
return {
|
||||
errorCodes: [SubscriptionsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
export type UnsubscribeSuccessPartial = Merge<
|
||||
UnsubscribeSuccess,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import {
|
|||
SubscriptionStatus,
|
||||
SubscriptionType,
|
||||
} from '../../src/generated/graphql'
|
||||
import { UNSUBSCRIBE_EMAIL_TEXT } from '../../src/services/subscriptions'
|
||||
import {
|
||||
UNSUBSCRIBE_EMAIL_TEXT,
|
||||
unsubscribe,
|
||||
} from '../../src/services/subscriptions'
|
||||
import * as sendEmail from '../../src/utils/sendEmail'
|
||||
import { createTestSubscription, createTestUser, deleteTestUser } from '../db'
|
||||
import { graphqlRequest, request } from '../util'
|
||||
|
|
@ -49,7 +52,7 @@ describe('Subscriptions API', () => {
|
|||
SubscriptionStatus.Unsubscribed
|
||||
)
|
||||
// create an rss feed subscription
|
||||
await createTestSubscription(
|
||||
const sub4 = await createTestSubscription(
|
||||
user,
|
||||
'sub_4',
|
||||
undefined,
|
||||
|
|
@ -57,7 +60,7 @@ describe('Subscriptions API', () => {
|
|||
undefined,
|
||||
SubscriptionType.Rss
|
||||
)
|
||||
subscriptions = [sub2, sub1]
|
||||
subscriptions = [sub4, sub2, sub1]
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
|
|
@ -88,7 +91,6 @@ describe('Subscriptions API', () => {
|
|||
|
||||
it('should return subscriptions', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.subscriptions.subscriptions).to.eql(
|
||||
subscriptions.map((sub) => ({
|
||||
id: sub.id,
|
||||
|
|
@ -97,6 +99,174 @@ describe('Subscriptions API', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('should return only newsletters when type newsletter supplied', async () => {
|
||||
query = `
|
||||
query {
|
||||
subscriptions(type: NEWSLETTER) {
|
||||
... on SubscriptionsSuccess {
|
||||
subscriptions {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
... on SubscriptionsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const newsletters = subscriptions.filter(
|
||||
(s) => s.type == SubscriptionType.Newsletter
|
||||
)
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.subscriptions.subscriptions).to.eql(
|
||||
newsletters.map((sub) => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
it('should not return inactive newsletters but should return inactive RSS', async () => {
|
||||
const sub5 = await createTestSubscription(
|
||||
user,
|
||||
'sub_5',
|
||||
undefined,
|
||||
SubscriptionStatus.Unsubscribed,
|
||||
undefined,
|
||||
SubscriptionType.Rss
|
||||
)
|
||||
|
||||
try {
|
||||
await createTestSubscription(
|
||||
user,
|
||||
'sub_6',
|
||||
undefined,
|
||||
SubscriptionStatus.Unsubscribed,
|
||||
undefined,
|
||||
SubscriptionType.Newsletter
|
||||
)
|
||||
const allSubscriptions = [sub5, ...subscriptions]
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.subscriptions.subscriptions).to.eql(
|
||||
allSubscriptions.map((sub) => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
}))
|
||||
)
|
||||
} finally {
|
||||
unsubscribe(sub5)
|
||||
}
|
||||
})
|
||||
|
||||
it('should not return other users subscriptions', async () => {
|
||||
// create test user and login
|
||||
const user2 = await createTestUser('fakeUser2')
|
||||
try {
|
||||
await createTestSubscription(
|
||||
user2,
|
||||
'sub_other',
|
||||
undefined,
|
||||
SubscriptionStatus.Unsubscribed,
|
||||
undefined,
|
||||
SubscriptionType.Rss
|
||||
)
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(res.body.data.subscriptions.subscriptions).to.eql(
|
||||
subscriptions.map((sub) => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
}))
|
||||
)
|
||||
} finally {
|
||||
deleteTestUser(user2.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('should not return other users subscriptions when type is set to RSS', async () => {
|
||||
query = `
|
||||
query {
|
||||
subscriptions(type: RSS) {
|
||||
... on SubscriptionsSuccess {
|
||||
subscriptions {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
... on SubscriptionsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const user2 = await createTestUser('fakeUser2')
|
||||
try {
|
||||
await createTestSubscription(
|
||||
user2,
|
||||
'sub_other',
|
||||
undefined,
|
||||
SubscriptionStatus.Unsubscribed,
|
||||
undefined,
|
||||
SubscriptionType.Rss
|
||||
)
|
||||
const rssItems = subscriptions.filter(
|
||||
(s) => s.type == SubscriptionType.Rss
|
||||
)
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(res.body.data.subscriptions.subscriptions).to.eql(
|
||||
rssItems.map((sub) => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
}))
|
||||
)
|
||||
} finally {
|
||||
deleteTestUser(user2.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('should not return other users subscriptions when type is set to NEWSLETTER', async () => {
|
||||
query = `
|
||||
query {
|
||||
subscriptions(type: NEWSLETTER) {
|
||||
... on SubscriptionsSuccess {
|
||||
subscriptions {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
... on SubscriptionsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const user2 = await createTestUser('fakeUser2')
|
||||
try {
|
||||
await createTestSubscription(
|
||||
user2,
|
||||
'sub_other',
|
||||
undefined,
|
||||
SubscriptionStatus.Unsubscribed,
|
||||
undefined,
|
||||
SubscriptionType.Rss
|
||||
)
|
||||
const newsletters = subscriptions.filter(
|
||||
(s) => s.type == SubscriptionType.Newsletter
|
||||
)
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(res.body.data.subscriptions.subscriptions).to.eql(
|
||||
newsletters.map((sub) => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
}))
|
||||
)
|
||||
} finally {
|
||||
deleteTestUser(user2.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('responds status code 400 when invalid query', async () => {
|
||||
const invalidQuery = `
|
||||
query {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export class ScrapingBeeHandler extends ContentHandler {
|
|||
|
||||
shouldPreHandle(url: string): boolean {
|
||||
const u = new URL(url)
|
||||
const hostnames = ['nytimes.com', 'news.google.com']
|
||||
const hostnames = ['nytimes.com', 'news.google.com', 'fool.ca']
|
||||
|
||||
return hostnames.some((h) => u.hostname.endsWith(h))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,11 @@ export default function ExtensionsInstallHelp({
|
|||
to Omnivore from your computer.
|
||||
<br />
|
||||
{!onboarding && (
|
||||
<Link passHref href="/help/saving-links">
|
||||
<a
|
||||
href="https://docs.omnivore.app/using/saving.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<StyledAnchor
|
||||
css={{
|
||||
color: '$grayTextContrast',
|
||||
|
|
@ -175,7 +179,7 @@ export default function ExtensionsInstallHelp({
|
|||
>
|
||||
Learn more about the browser extension ->
|
||||
</StyledAnchor>
|
||||
</Link>
|
||||
</a>
|
||||
)}
|
||||
</StyledText>
|
||||
<HStack
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export function InfoLink(props: InfoLinkProps): JSX.Element {
|
|||
<a
|
||||
href={props.href}
|
||||
style={{ textDecoration: 'none', width: '24px', height: '24px' }}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<TooltipWrapped
|
||||
tooltipContent="Learn More"
|
||||
|
|
|
|||
|
|
@ -149,7 +149,11 @@ export default function MobileInstallHelp({
|
|||
share extension.
|
||||
<br />
|
||||
{!onboarding && (
|
||||
<Link passHref href="/help/saving-links">
|
||||
<a
|
||||
href="https://docs.omnivore.app/using/saving.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<StyledAnchor
|
||||
css={{
|
||||
color: '$grayTextContrast',
|
||||
|
|
@ -160,7 +164,7 @@ export default function MobileInstallHelp({
|
|||
>
|
||||
Learn more about the iOS app ->
|
||||
</StyledAnchor>
|
||||
</Link>
|
||||
</a>
|
||||
)}
|
||||
</StyledText>
|
||||
<HStack
|
||||
|
|
|
|||
43
packages/web/components/elements/icons/ArrowRightIcon.tsx
Normal file
43
packages/web/components/elements/icons/ArrowRightIcon.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/* eslint-disable functional/no-class */
|
||||
/* eslint-disable functional/no-this-expression */
|
||||
import { IconProps } from './IconProps'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export class ArrowRightIcon extends React.Component<IconProps> {
|
||||
render() {
|
||||
const size = (this.props.size || 26).toString()
|
||||
const color = (this.props.color || '#2A2A2A').toString()
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M3.33398 8H12.6673"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 10.6667L12.6667 8"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 5.33203L12.6667 7.9987"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable functional/no-class */
|
||||
/* eslint-disable functional/no-this-expression */
|
||||
import { IconProps } from './IconProps'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export class ToggleCaretDownIcon extends React.Component<IconProps> {
|
||||
render() {
|
||||
const size = (this.props.size || 26).toString()
|
||||
const color = (this.props.color || '#2A2A2A').toString()
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M6.57812 10.3379L12.8281 16.5879L19.0781 10.3379"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable functional/no-class */
|
||||
/* eslint-disable functional/no-this-expression */
|
||||
import { IconProps } from './IconProps'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export class ToggleCaretLeftIcon extends React.Component<IconProps> {
|
||||
render() {
|
||||
const size = (this.props.size || 26).toString()
|
||||
const color = (this.props.color || '#2A2A2A').toString()
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M15.9531 6.77344L9.70312 13.0234L15.9531 19.2734"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable functional/no-class */
|
||||
/* eslint-disable functional/no-this-expression */
|
||||
import { IconProps } from './IconProps'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export class ToggleCaretRightIcon extends React.Component<IconProps> {
|
||||
render() {
|
||||
const size = (this.props.size || 26).toString()
|
||||
const color = (this.props.color || '#2A2A2A').toString()
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M9.70313 19.5742L15.9531 13.3242L9.70312 7.07422"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
SpanBox,
|
||||
} from '../elements/LayoutPrimitives'
|
||||
import { VStack, HStack, SpanBox } from '../elements/LayoutPrimitives'
|
||||
import { StyledText } from '../elements/StyledText'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '../elements/Button'
|
||||
|
|
@ -22,11 +18,14 @@ export function ErrorLayout(props: ErrorLayoutProps): JSX.Element {
|
|||
return (
|
||||
<VStack alignment="center" distribution="start" css={{ height: '100%' }}>
|
||||
<HStack alignment="center" css={{ mt: '64px', verticalAlign: 'middle' }}>
|
||||
<StyledText style="headline" css={{
|
||||
marginRight: '25px',
|
||||
padding: '32px',
|
||||
borderRight: '1px solid $grayText',
|
||||
}}>
|
||||
<StyledText
|
||||
style="headline"
|
||||
css={{
|
||||
marginRight: '25px',
|
||||
padding: '32px',
|
||||
borderRight: '1px solid $grayText',
|
||||
}}
|
||||
>
|
||||
{props.statusCode}
|
||||
</StyledText>
|
||||
<StyledText style="body">
|
||||
|
|
@ -34,9 +33,11 @@ export function ErrorLayout(props: ErrorLayoutProps): JSX.Element {
|
|||
</StyledText>
|
||||
</HStack>
|
||||
<SpanBox css={{ height: '64px' }} />
|
||||
<Link passHref href={viewerData?.me ? "/home" : "/login"}>
|
||||
<Button style="ctaDarkYellow">{viewerData?.me ? "Go Home" : "Login"}</Button>
|
||||
<Link passHref href={viewerData?.me ? '/home' : '/login'}>
|
||||
<Button style="ctaDarkYellow">
|
||||
{viewerData?.me ? 'Go Home' : 'Login'}
|
||||
</Button>
|
||||
</Link>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -472,7 +472,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
async (action: HighlightAction, param?: string) => {
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
if (focusedHighlight?.annotation == undefined) {
|
||||
if ((focusedHighlight?.annotation ?? '').length === 0) {
|
||||
await removeHighlightCallback()
|
||||
} else {
|
||||
setConfirmDeleteHighlightWithNoteId(focusedHighlight?.id)
|
||||
|
|
|
|||
|
|
@ -1,51 +1,237 @@
|
|||
import Link from 'next/link'
|
||||
import { Book } from 'phosphor-react'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { VStack } from '../../elements/LayoutPrimitives'
|
||||
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { useMemo } from 'react'
|
||||
import { searchQuery } from '../../../lib/networking/queries/search'
|
||||
import { LIBRARY_LEFT_MENU_WIDTH } from './LibraryFilterMenu'
|
||||
import { LayoutType } from './HomeFeedContainer'
|
||||
import { ArrowRightIcon } from '../../elements/icons/ArrowRightIcon'
|
||||
|
||||
type EmptyLibraryProps = {
|
||||
searchTerm: string | undefined
|
||||
onAddLinkClicked: () => void
|
||||
|
||||
layoutType: LayoutType
|
||||
}
|
||||
|
||||
export function EmptyLibrary(props: EmptyLibraryProps): JSX.Element {
|
||||
type MessageType = 'feed' | 'newsletter' | 'library'
|
||||
|
||||
type HelpMessageProps = {
|
||||
type: MessageType
|
||||
}
|
||||
|
||||
const HelpMessage = (props: HelpMessageProps) => {
|
||||
switch (props.type) {
|
||||
case 'library':
|
||||
return (
|
||||
<>
|
||||
You can add a link or read more about Omnivore's{' '}
|
||||
<a
|
||||
href="https://docs.omnivore.app/using/search.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
advanced search
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)
|
||||
case 'feed':
|
||||
return (
|
||||
<>
|
||||
You can subscribe to RSS feeds using the{' '}
|
||||
<Link href="/settings/feeds" passHref>
|
||||
feeds page
|
||||
</Link>
|
||||
. Learn more about feeds at 's{' '}
|
||||
<a
|
||||
href="https://docs.omnivore.app/using/feeds.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docs.omnivore.app/using/feeds.html
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)
|
||||
case 'newsletter':
|
||||
return (
|
||||
<>
|
||||
Create email addresses that can be used to subscribe to newsletters on
|
||||
the{' '}
|
||||
<Link href="/settings/emails" passHref>
|
||||
emails page
|
||||
</Link>
|
||||
. Learn more about reading newsletters in Omnivore at 's{' '}
|
||||
<a
|
||||
href="https://docs.omnivore.app/using/inbox.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docs.omnivore.app/using/inbox.html
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <></>
|
||||
}
|
||||
|
||||
export const ErrorBox = (props: HelpMessageProps) => {
|
||||
const errorTitle = useMemo(() => {
|
||||
switch (props.type) {
|
||||
case 'feed':
|
||||
return 'You do not have any feed items matching this query.'
|
||||
case 'newsletter':
|
||||
return 'You do not have any newsletter item matching this query.'
|
||||
}
|
||||
return 'No results found for this query.'
|
||||
}, [props.type])
|
||||
|
||||
return (
|
||||
<VStack
|
||||
alignment="center"
|
||||
distribution="center"
|
||||
<Box
|
||||
css={{
|
||||
color: '$grayTextContrast',
|
||||
textAlign: 'center',
|
||||
paddingTop: '88px',
|
||||
flex: '1',
|
||||
width: 'fit-content',
|
||||
borderRadius: '5px',
|
||||
background: 'rgba(255, 59, 48, 0.3)',
|
||||
fontSize: '15px',
|
||||
fontFamily: '$inter',
|
||||
fontWeight: '500',
|
||||
color: '$thTextContrast',
|
||||
padding: '10px',
|
||||
'@smDown': {
|
||||
width: '100%',
|
||||
},
|
||||
'@xlgDown': {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Book size={44} color={theme.colors.grayTextContrast.toString()} />
|
||||
<StyledText style="fixedHeadline" css={{ color: '$grayTextContrast' }}>
|
||||
No results found.
|
||||
</StyledText>
|
||||
|
||||
<StyledText style="footnote" css={{ color: '$grayTextContrast' }}>
|
||||
You can add a link or read more about Omnivore's{' '}
|
||||
<a
|
||||
href="https://docs.omnivore.app/using/search.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
advanced search
|
||||
</a>
|
||||
.
|
||||
</StyledText>
|
||||
|
||||
<Button
|
||||
style="ctaDarkYellow"
|
||||
onClick={() => {
|
||||
props.onAddLinkClicked()
|
||||
}}
|
||||
>
|
||||
Add Link
|
||||
</Button>
|
||||
</VStack>
|
||||
{errorTitle}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const SuggestionBox = (props: HelpMessageProps) => {
|
||||
const helpMessage = useMemo(() => {
|
||||
switch (props.type) {
|
||||
case 'feed':
|
||||
return 'Want to add an RSS or Atom Subscription?'
|
||||
case 'newsletter':
|
||||
return 'Create an Omnivore email address and subscribe to newsletters.'
|
||||
}
|
||||
return "Add a link or read more about Omnivore's Advanced Search."
|
||||
}, [props.type])
|
||||
|
||||
const helpTarget = useMemo(() => {
|
||||
switch (props.type) {
|
||||
case 'feed':
|
||||
return '/settings/feeds'
|
||||
case 'newsletter':
|
||||
return '/settings/emails'
|
||||
}
|
||||
return 'https://docs.omnivore.app/'
|
||||
}, [props.type])
|
||||
|
||||
return (
|
||||
<HStack
|
||||
css={{
|
||||
gap: '10px',
|
||||
width: 'fit-content',
|
||||
borderRadius: '5px',
|
||||
background: '$thBackground3',
|
||||
fontSize: '15px',
|
||||
fontFamily: '$inter',
|
||||
fontWeight: '500',
|
||||
color: '$thTextContrast',
|
||||
padding: '10px',
|
||||
justifyContent: 'flex-start',
|
||||
'@smDown': {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{helpMessage}
|
||||
<SpanBox css={{ cursor: 'pointer' }}>
|
||||
<Link href={helpTarget} passHref>
|
||||
<SpanBox
|
||||
css={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '$omnivoreCtaYellow',
|
||||
gap: '2px',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<>Click Here</>
|
||||
<ArrowRightIcon
|
||||
size={25}
|
||||
color={theme.colors.omnivoreCtaYellow.toString()}
|
||||
/>
|
||||
</SpanBox>
|
||||
</Link>
|
||||
</SpanBox>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
export const EmptyLibrary = (props: EmptyLibraryProps) => {
|
||||
const type = useMemo<MessageType>(() => {
|
||||
if (props.searchTerm) {
|
||||
switch (props.searchTerm) {
|
||||
case 'label:RSS':
|
||||
return 'feed'
|
||||
case 'label:Newsletter':
|
||||
return 'newsletter'
|
||||
}
|
||||
}
|
||||
return 'library'
|
||||
}, [props])
|
||||
|
||||
return (
|
||||
<Box
|
||||
css={{
|
||||
display: 'inline-flex',
|
||||
color: '$grayTextContrast',
|
||||
gap: '10px',
|
||||
pl: '0px',
|
||||
|
||||
width: '100%',
|
||||
'@media (max-width: 1300px)': {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
|
||||
'@media (max-width: 768px)': {
|
||||
p: '15px',
|
||||
},
|
||||
|
||||
'@media (min-width: 768px)': {
|
||||
pl: '15px',
|
||||
width: `calc(100vw - ${LIBRARY_LEFT_MENU_WIDTH})`,
|
||||
},
|
||||
'@media (min-width: 930px)': {
|
||||
pl: '0px',
|
||||
width: props.layoutType == 'GRID_LAYOUT' ? '660px' : '640px',
|
||||
},
|
||||
'@media (min-width: 1280px)': {
|
||||
pl: '0px',
|
||||
width: '1000px',
|
||||
},
|
||||
'@media (min-width: 1600px)': {
|
||||
pl: '0px',
|
||||
width: '1340px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ErrorBox type={type} />
|
||||
<SuggestionBox type={type} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Action, createAction, useKBar, useRegisterActions } from 'kbar'
|
|||
import debounce from 'lodash/debounce'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast, { Toaster } from "react-hot-toast"
|
||||
import toast, { Toaster } from 'react-hot-toast'
|
||||
import TopBarProgress from 'react-topbar-progress-indicator'
|
||||
import { useFetchMore } from '../../../lib/hooks/useFetchMoreScroll'
|
||||
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
|
||||
|
|
@ -48,8 +48,9 @@ import {
|
|||
} from '../../../lib/toastHelpers'
|
||||
import { SetPageLabelsModalPresenter } from '../article/SetLabelsModalPresenter'
|
||||
import { NotebookPresenter } from '../article/NotebookPresenter'
|
||||
import { saveUrlMutation } from "../../../lib/networking/mutations/saveUrlMutation"
|
||||
import { articleQuery } from "../../../lib/networking/queries/useGetArticleQuery"
|
||||
import { saveUrlMutation } from '../../../lib/networking/mutations/saveUrlMutation'
|
||||
import { articleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { searchQuery } from '../../../lib/networking/queries/search'
|
||||
|
||||
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
|
||||
export type LibraryMode = 'reads' | 'highlights'
|
||||
|
|
@ -70,7 +71,7 @@ const debouncedFetchSearchResults = debounce((query, cb) => {
|
|||
// We set a relatively high delay for the refresh at the end, as it's likely there's an issue
|
||||
// in processing. We give it the best attempt to be able to resolve, but if it doesn't we set
|
||||
// the state as Failed. On refresh it will try again if the backend sends "PROCESSING"
|
||||
const TIMEOUT_DELAYS = [1000, 2000, 2500, 3500, 5000, 10000, 60000];
|
||||
const TIMEOUT_DELAYS = [1000, 2000, 2500, 3500, 5000, 10000, 60000]
|
||||
|
||||
export function HomeFeedContainer(): JSX.Element {
|
||||
const { viewerData } = useGetViewerQuery()
|
||||
|
|
@ -179,58 +180,69 @@ export function HomeFeedContainer(): JSX.Element {
|
|||
return itemsPages[itemsPages.length - 1].search.pageInfo.hasNextPage
|
||||
}, [itemsPages])
|
||||
|
||||
|
||||
const libraryItems = useMemo(() => {
|
||||
const items =
|
||||
itemsPages?.flatMap((ad) => {
|
||||
return ad.search.edges.map(it => ({ ...it, isLoading: it.node.state === 'PROCESSING'}));
|
||||
return ad.search.edges.map((it) => ({
|
||||
...it,
|
||||
isLoading: it.node.state === 'PROCESSING',
|
||||
}))
|
||||
}) || []
|
||||
return items
|
||||
}, [itemsPages, performActionOnItem])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout : NodeJS.Timeout[] = []
|
||||
const timeout: NodeJS.Timeout[] = []
|
||||
|
||||
const items =
|
||||
(itemsPages?.flatMap((ad) => {
|
||||
return ad.search.edges.map(it => ({ ...it, isLoading: it.node.state === 'PROCESSING'}));
|
||||
}) || [])
|
||||
.filter(it => it.isLoading);
|
||||
const items = (
|
||||
itemsPages?.flatMap((ad) => {
|
||||
return ad.search.edges.map((it) => ({
|
||||
...it,
|
||||
isLoading: it.node.state === 'PROCESSING',
|
||||
}))
|
||||
}) || []
|
||||
).filter((it) => it.isLoading)
|
||||
|
||||
items.map(async (item) => {
|
||||
let startIdx = 0;
|
||||
let startIdx = 0
|
||||
|
||||
const seeIfUpdated = async () => {
|
||||
if (startIdx > TIMEOUT_DELAYS.length) {
|
||||
item.node.state = State.FAILED;
|
||||
item.node.state = State.FAILED
|
||||
return
|
||||
}
|
||||
|
||||
const username = viewerData?.me?.profile.username
|
||||
const itemsToUpdate = libraryItems.filter(it => it.isLoading);
|
||||
const itemsToUpdate = libraryItems.filter((it) => it.isLoading)
|
||||
|
||||
if (itemsToUpdate.length > 0) {
|
||||
const link = await articleQuery({ username, slug: item.node.slug, includeFriendsHighlights: false })
|
||||
const link = await articleQuery({
|
||||
username,
|
||||
slug: item.node.slug,
|
||||
includeFriendsHighlights: false,
|
||||
})
|
||||
|
||||
if (link && link.state != "PROCESSING") {
|
||||
const updatedArticle = { ...item };
|
||||
if (link && link.state != 'PROCESSING') {
|
||||
const updatedArticle = { ...item }
|
||||
updatedArticle.node = { ...item.node, ...link }
|
||||
updatedArticle.isLoading = false;
|
||||
updatedArticle.isLoading = false
|
||||
console.log(`Updating Metadata of ${item.node.slug}.`)
|
||||
performActionOnItem('update-item', updatedArticle);
|
||||
return;
|
||||
performActionOnItem('update-item', updatedArticle)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Trying to get the metadata of item ${item.node.slug}... Retry ${startIdx} of 5`);
|
||||
console.log(
|
||||
`Trying to get the metadata of item ${item.node.slug}... Retry ${startIdx} of 5`
|
||||
)
|
||||
timeout.push(setTimeout(seeIfUpdated, TIMEOUT_DELAYS[startIdx++]))
|
||||
}
|
||||
}
|
||||
|
||||
await seeIfUpdated();
|
||||
});
|
||||
await seeIfUpdated()
|
||||
})
|
||||
|
||||
return () => {
|
||||
timeout.forEach(clearTimeout);
|
||||
timeout.forEach(clearTimeout)
|
||||
}
|
||||
}, [itemsPages])
|
||||
|
||||
|
|
@ -769,36 +781,39 @@ export function HomeFeedContainer(): JSX.Element {
|
|||
[itemsPages, multiSelectMode, checkedItems]
|
||||
)
|
||||
|
||||
const handleLinkSubmission =
|
||||
async (link: string, timezone: string, locale: string) => {
|
||||
const result = await saveUrlMutation(link, timezone, locale)
|
||||
if (result) {
|
||||
toast(
|
||||
() => (
|
||||
<Box>
|
||||
Link Saved
|
||||
<span style={{ padding: '16px' }} />
|
||||
<Button
|
||||
style="ctaDarkYellow"
|
||||
autoFocus
|
||||
onClick={() => {
|
||||
window.location.href = `/article?url=${encodeURIComponent(
|
||||
link
|
||||
)}`
|
||||
}}
|
||||
>
|
||||
Read Now
|
||||
</Button>
|
||||
</Box>
|
||||
),
|
||||
{ position: 'bottom-right' }
|
||||
)
|
||||
const id = result.url?.match(/[^/]+$/)?.[0] ?? "";
|
||||
performActionOnItem('refresh', undefined as unknown as any)
|
||||
} else {
|
||||
showErrorToast('Error saving link', { position: 'bottom-right' })
|
||||
}
|
||||
};
|
||||
const handleLinkSubmission = async (
|
||||
link: string,
|
||||
timezone: string,
|
||||
locale: string
|
||||
) => {
|
||||
const result = await saveUrlMutation(link, timezone, locale)
|
||||
if (result) {
|
||||
toast(
|
||||
() => (
|
||||
<Box>
|
||||
Link Saved
|
||||
<span style={{ padding: '16px' }} />
|
||||
<Button
|
||||
style="ctaDarkYellow"
|
||||
autoFocus
|
||||
onClick={() => {
|
||||
window.location.href = `/article?url=${encodeURIComponent(
|
||||
link
|
||||
)}`
|
||||
}}
|
||||
>
|
||||
Read Now
|
||||
</Button>
|
||||
</Box>
|
||||
),
|
||||
{ position: 'bottom-right' }
|
||||
)
|
||||
const id = result.url?.match(/[^/]+$/)?.[0] ?? ''
|
||||
performActionOnItem('refresh', undefined as unknown as any)
|
||||
} else {
|
||||
showErrorToast('Error saving link', { position: 'bottom-right' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeFeedGrid
|
||||
|
|
@ -902,7 +917,11 @@ type HomeFeedContentProps = {
|
|||
item: LibraryItem | undefined
|
||||
) => Promise<void>
|
||||
|
||||
handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise<void>,
|
||||
handleLinkSubmission: (
|
||||
link: string,
|
||||
timezone: string,
|
||||
locale: string
|
||||
) => Promise<void>
|
||||
|
||||
setIsChecked: (itemId: string, set: boolean) => void
|
||||
itemIsChecked: (itemId: string) => boolean
|
||||
|
|
@ -986,7 +1005,10 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
|
|||
)}
|
||||
|
||||
{props.showAddLinkModal && (
|
||||
<AddLinkModal handleLinkSubmission={props.handleLinkSubmission} onOpenChange={() => props.setShowAddLinkModal(false)} />
|
||||
<AddLinkModal
|
||||
handleLinkSubmission={props.handleLinkSubmission}
|
||||
onOpenChange={() => props.setShowAddLinkModal(false)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
|
@ -1044,6 +1066,8 @@ function LibraryItemsLayout(props: LibraryItemsLayoutProps): JSX.Element {
|
|||
>
|
||||
{!props.isValidating && props.items.length == 0 ? (
|
||||
<EmptyLibrary
|
||||
layoutType={props.layout}
|
||||
searchTerm={props.searchTerm}
|
||||
onAddLinkClicked={() => {
|
||||
props.setShowAddLinkModal(true)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { ReactNode, useMemo } from 'react'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { CaretRight, Circle, DotsThree } from 'phosphor-react'
|
||||
import { CaretRight, Circle } from 'phosphor-react'
|
||||
import { useGetSubscriptionsQuery } from '../../../lib/networking/queries/useGetSubscriptionsQuery'
|
||||
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
|
||||
import { Label } from '../../../lib/networking/fragments/labelFragment'
|
||||
|
|
@ -13,6 +12,11 @@ import { LogoBox } from '../../elements/LogoBox'
|
|||
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
|
||||
import { useGetSavedSearchQuery} from '../../../lib/networking/queries/useGetSavedSearchQuery'
|
||||
import { SavedSearch } from "../../../lib/networking/fragments/savedSearchFragment"
|
||||
import { ToggleCaretDownIcon } from '../../elements/icons/ToggleCaretDownIcon'
|
||||
import { ToggleCaretLeftIcon } from '../../elements/icons/ToggleCaretLeftIcon'
|
||||
import Link from 'next/link'
|
||||
import { ArrowRightIcon } from '../../elements/icons/ArrowRightIcon'
|
||||
import { ToggleCaretRightIcon } from '../../elements/icons/ToggleCaretRightIcon'
|
||||
|
||||
export const LIBRARY_LEFT_MENU_WIDTH = '233px'
|
||||
|
||||
|
|
@ -109,11 +113,19 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
|
|||
[isLoading]
|
||||
)
|
||||
|
||||
const [collapsed, setCollapsed] = usePersistedState<boolean>({
|
||||
key: `--saved-searches-collapsed`,
|
||||
initialValue: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<MenuPanel title="Saved Searches" editTitle="Edit Saved Searches" editFunc={() => {
|
||||
window.location.href = '/settings/saved-searches'
|
||||
}}>
|
||||
{sortedSearches && sortedSearches?.map((item) => (
|
||||
<MenuPanel title="Saved Searches"
|
||||
editTitle="Edit Saved Searches"
|
||||
editFunc={() => window.location.href = '/settings/saved-searches' }
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
>
|
||||
{!collapsed && sortedSearches && sortedSearches?.map((item) => (
|
||||
<FilterButton
|
||||
key={item.name}
|
||||
text={item.name}
|
||||
|
|
@ -129,8 +141,8 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
|
|||
|
||||
function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
|
||||
const { subscriptions } = useGetSubscriptionsQuery()
|
||||
const [viewAll, setViewAll] = usePersistedState<boolean>({
|
||||
key: `--subscriptions-view-all`,
|
||||
const [collapsed, setCollapsed] = usePersistedState<boolean>({
|
||||
key: `--subscriptions-collapsed`,
|
||||
initialValue: false,
|
||||
})
|
||||
|
||||
|
|
@ -151,37 +163,46 @@ function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
|
|||
[subscriptions]
|
||||
)
|
||||
|
||||
if (!subscriptions || subscriptions.length < 1) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuPanel
|
||||
title="Subscriptions"
|
||||
editTitle="Edit Subscriptions"
|
||||
editFunc={() => {
|
||||
window.location.href = '/settings/subscriptions'
|
||||
}}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
>
|
||||
{subscriptions.slice(0, viewAll ? undefined : 4).map((item) => {
|
||||
return (
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<FilterButton filterTerm={`label:RSS`} text="Feeds" {...props} />
|
||||
<FilterButton
|
||||
key={item.id}
|
||||
filterTerm={`subscription:\"${item.name}\"`}
|
||||
text={item.name}
|
||||
filterTerm={`label:Newsletter`}
|
||||
text="Newsletters"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<ViewAllButton state={viewAll} setState={setViewAll} />
|
||||
{(subscriptions ?? []).map((item) => {
|
||||
return (
|
||||
<FilterButton
|
||||
key={item.id}
|
||||
filterTerm={`subscription:\"${item.name}\"`}
|
||||
text={item.name}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<EditButton
|
||||
title="Edit Subscriptions"
|
||||
destination="/settings/subscriptions"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SpanBox css={{ mb: '10px' }} />
|
||||
)}
|
||||
</MenuPanel>
|
||||
)
|
||||
}
|
||||
|
||||
function Labels(props: LibraryFilterMenuProps): JSX.Element {
|
||||
const { labels } = useGetLabelsQuery()
|
||||
const [viewAll, setViewAll] = usePersistedState<boolean>({
|
||||
key: `--labels-view-all`,
|
||||
const [collapsed, setCollapsed] = usePersistedState<boolean>({
|
||||
key: `--labels-collapsed`,
|
||||
initialValue: false,
|
||||
})
|
||||
|
||||
|
|
@ -196,14 +217,17 @@ function Labels(props: LibraryFilterMenuProps): JSX.Element {
|
|||
title="Labels"
|
||||
editTitle="Edit Labels"
|
||||
hideBottomBorder={true}
|
||||
editFunc={() => {
|
||||
window.location.href = '/settings/labels'
|
||||
}}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
>
|
||||
{sortedLabels.slice(0, viewAll ? undefined : 4).map((item) => {
|
||||
return <LabelButton key={item.id} label={item} {...props} />
|
||||
})}
|
||||
<ViewAllButton state={viewAll} setState={setViewAll} />
|
||||
{!collapsed && (
|
||||
<>
|
||||
{sortedLabels.map((item) => {
|
||||
return <LabelButton key={item.id} label={item} {...props} />
|
||||
})}
|
||||
<EditButton title="Edit Labels" destination="/settings/labels" />
|
||||
</>
|
||||
)}
|
||||
</MenuPanel>
|
||||
)
|
||||
}
|
||||
|
|
@ -214,6 +238,8 @@ type MenuPanelProps = {
|
|||
editFunc?: () => void
|
||||
editTitle?: string
|
||||
hideBottomBorder?: boolean
|
||||
collapsed: boolean
|
||||
setCollapsed: (collapsed: boolean) => void
|
||||
}
|
||||
|
||||
function MenuPanel(props: MenuPanelProps): JSX.Element {
|
||||
|
|
@ -230,7 +256,7 @@ function MenuPanel(props: MenuPanelProps): JSX.Element {
|
|||
alignment="start"
|
||||
distribution="start"
|
||||
>
|
||||
<HStack css={{ width: '100%' }} distribution="start" alignment="start">
|
||||
<HStack css={{ width: '100%' }} distribution="start" alignment="center">
|
||||
<StyledText
|
||||
css={{
|
||||
fontFamily: 'Inter',
|
||||
|
|
@ -239,54 +265,40 @@ function MenuPanel(props: MenuPanelProps): JSX.Element {
|
|||
lineHeight: '125%',
|
||||
color: '$thLibraryMenuPrimary',
|
||||
pl: '10px',
|
||||
my: '20px',
|
||||
mt: '20px',
|
||||
mb: '10px',
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</StyledText>
|
||||
<SpanBox
|
||||
css={{
|
||||
my: '15px',
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
mt: '10px',
|
||||
marginLeft: 'auto',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
{props.editTitle && props.editFunc && (
|
||||
<Dropdown
|
||||
triggerElement={
|
||||
<Box
|
||||
css={{
|
||||
display: 'flex',
|
||||
height: '30px',
|
||||
width: '30px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '1000px',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
bg: '$thBackground4',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DotsThree
|
||||
size={25}
|
||||
weight="bold"
|
||||
color={theme.colors.thTextSubtle2.toString()}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<DropdownOption
|
||||
title={props.editTitle}
|
||||
onSelect={() => {
|
||||
if (props.editFunc) {
|
||||
props.editFunc()
|
||||
}
|
||||
}}
|
||||
<Button
|
||||
style="articleActionIcon"
|
||||
onClick={(event) => {
|
||||
props.setCollapsed(!props.collapsed)
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{props.collapsed ? (
|
||||
<ToggleCaretRightIcon
|
||||
size={15}
|
||||
color={theme.colors.thLibraryMenuPrimary.toString()}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
) : (
|
||||
<ToggleCaretDownIcon
|
||||
size={15}
|
||||
color={theme.colors.thLibraryMenuPrimary.toString()}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</SpanBox>
|
||||
</HStack>
|
||||
{props.children}
|
||||
|
|
@ -450,34 +462,43 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
type ViewAllButtonProps = {
|
||||
state: boolean
|
||||
setState: (state: boolean) => void
|
||||
type EditButtonProps = {
|
||||
title: string
|
||||
destination: string
|
||||
}
|
||||
|
||||
function ViewAllButton(props: ViewAllButtonProps): JSX.Element {
|
||||
function EditButton(props: EditButtonProps): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
style="ghost"
|
||||
css={{
|
||||
display: 'flex',
|
||||
pl: '10px',
|
||||
color: '#898989',
|
||||
fontWeight: '600',
|
||||
fontSize: '12px',
|
||||
py: '20px',
|
||||
gap: '2px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
props.setState(!props.state)
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
{props.state ? 'Hide' : 'View All'}
|
||||
{props.state ? null : (
|
||||
<CaretRight size={12} color="#898989" weight="bold" />
|
||||
)}
|
||||
</Button>
|
||||
<Link href={props.destination} passHref>
|
||||
<SpanBox
|
||||
css={{
|
||||
ml: '10px',
|
||||
mb: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
height: '32px',
|
||||
|
||||
fontSize: '14px',
|
||||
fontWeight: 'regular',
|
||||
fontFamily: '$display',
|
||||
color: '$thLibraryMenuUnselected',
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</SpanBox>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export enum SubscriptionType {
|
|||
export type Subscription = {
|
||||
id: string
|
||||
name: string
|
||||
type: SubscriptionType
|
||||
newsletterEmail?: string
|
||||
|
||||
url?: string
|
||||
|
|
@ -38,16 +39,16 @@ type SubscriptionsData = {
|
|||
}
|
||||
|
||||
export function useGetSubscriptionsQuery(
|
||||
subscriptionType = SubscriptionType.NEWSLETTER,
|
||||
sortBy = 'UPDATED_TIME'
|
||||
): SubscriptionsQueryResponse {
|
||||
const query = gql`
|
||||
query GetSubscriptions {
|
||||
subscriptions(sort: { by: ${sortBy} }, type: ${subscriptionType}) {
|
||||
subscriptions(sort: { by: ${sortBy} }) {
|
||||
... on SubscriptionsSuccess {
|
||||
subscriptions {
|
||||
id
|
||||
name
|
||||
type
|
||||
newsletterEmail
|
||||
url
|
||||
description
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Box, HStack } from '../../components/elements/LayoutPrimitives'
|
||||
import { SettingsLayout } from '../../components/templates/SettingsLayout'
|
||||
import { Button } from '../../components/elements/Button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function Emails(): JSX.Element {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Box
|
||||
css={{
|
||||
m: '42px',
|
||||
maxWidth: '640px',
|
||||
color: '$grayText',
|
||||
img: {
|
||||
maxWidth: '85%',
|
||||
},
|
||||
'@smDown': {
|
||||
m: '16px',
|
||||
maxWidth: '85%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h1>Omnivore Email Addresses</h1>
|
||||
<hr />
|
||||
<p>
|
||||
An Omnivore email address will receive email, detect whether the email
|
||||
is a PDF document or newsletter, and add the content to your library.
|
||||
</p>
|
||||
<p>
|
||||
If Omnivore doesn't think the item should be added to your
|
||||
library, it will be forwarded to the email address you used when you
|
||||
registered for Omnivore (from <code>msgs@omnivore.app</code>).
|
||||
</p>
|
||||
|
||||
<h2>Sending PDFs to your Omnivore Email Address</h2>
|
||||
<p>
|
||||
Add PDFs to your Omnivore library by sending them to your Omnivore
|
||||
email address. If there is a subject line in the email, it will be
|
||||
used as the title of the PDF. If there is no subject line, the
|
||||
filename will be used as the title.
|
||||
</p>
|
||||
|
||||
<h2>Read all your newsletters in Omnivore</h2>
|
||||
<p>
|
||||
Subscribe to newsletters with your Omnivore email address and they
|
||||
will be added to your library when we receive them.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Link href="/help/newsletters">
|
||||
Learn more about setting up newsletters
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<HStack alignment="center" css={{ mb: '32px', width: '100%' }}>
|
||||
<Link passHref href="/settings/emails">
|
||||
<Button style="ctaDarkYellow">Get Started</Button>
|
||||
</Link>
|
||||
</HStack>
|
||||
</Box>
|
||||
<Box css={{ height: '120px' }} />
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Box } from '../../components/elements/LayoutPrimitives'
|
||||
import { SettingsLayout } from '../../components/templates/SettingsLayout'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function Labels(): JSX.Element {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Box
|
||||
css={{
|
||||
m: '42px',
|
||||
maxWidth: '640px',
|
||||
color: '$grayText',
|
||||
img: {
|
||||
maxWidth: '85%',
|
||||
},
|
||||
'@smDown': {
|
||||
m: '16px',
|
||||
maxWidth: '85%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h1>Organize your Omnivore library with labels</h1>
|
||||
<hr />
|
||||
<h2>Introduction</h2>
|
||||
<p>
|
||||
Labels allow you to group and search for content in Omnivore. A saved
|
||||
page can have multiple labels and search results can be filtered by
|
||||
label.
|
||||
</p>
|
||||
<h2>Adding labels to a page on iOS</h2>
|
||||
<p>
|
||||
On iOS you add and remove labels from a page using the Assign Labels
|
||||
modal.
|
||||
</p>
|
||||
<p>
|
||||
You can open the Assign Labels modal from the home view or the reader
|
||||
view. In the home view long press on an item and choose Edit Labels
|
||||
from the dropdowm menu. In the reader view use the top right menu
|
||||
button.
|
||||
</p>
|
||||
<h2>Adding labels to a page on the web</h2>
|
||||
<p>
|
||||
On the web you add and remove labels from a page using the Assign
|
||||
Labels dropdown or modal depending on your screen size. For larger
|
||||
monitors you will see the Labels button on the left hand side of the
|
||||
reader view. For smaller monitors you will see the labels dropdown at
|
||||
the top of your screen.
|
||||
</p>
|
||||
<p>
|
||||
You can also use keyboard commands to open the assign labels modal. On
|
||||
the reader view tap the <code>l</code> key. Once open you can use the
|
||||
up/down arrow keys, or the tab key to navigate the available labels,
|
||||
and the Enter key to toggle a label.
|
||||
</p>
|
||||
<h2>Searching by label on iOS</h2>
|
||||
<p>
|
||||
On iOS you can use the Labels search chip to search for labels. This
|
||||
will open a modal allowing you to assign multiple labels to your
|
||||
search. This will become an <code>OR </code>
|
||||
search, meaning if you add multiple labels to your search, pages that
|
||||
have any of the labels will be returned.
|
||||
</p>
|
||||
<h2>Searching by label with Advanced Search</h2>
|
||||
<p>
|
||||
Omnivore's advanced search syntax supports searching for multiple
|
||||
labels using
|
||||
<code>AND</code> and <code>OR</code> clauses. You can also negate a
|
||||
label search to find all pages that do not have a certain label.
|
||||
</p>
|
||||
<p>Some examples:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>-label:Newsletter</code> finds all pages that have the label{' '}
|
||||
<code>Newsletter</code>
|
||||
</li>
|
||||
<li>
|
||||
<code>label:Cooking,Fitness</code> finds all your pages with either
|
||||
the <code>Cooking</code> or <code>Fitness</code> labels
|
||||
</li>
|
||||
<li>
|
||||
<code>label:Newsletter label:Surfing</code> finds all pages with
|
||||
both the <code>Newsletter</code> and <code>Surfing</code> labels
|
||||
</li>
|
||||
<li>
|
||||
<code>label:Coding -label:Newsletter</code> finds all pages with the{' '}
|
||||
<code>Coding</code> label that do not have the{' '}
|
||||
<code>Newsletter</code> label
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Editing your list of labels</h2>
|
||||
<p>
|
||||
The{' '}
|
||||
<Link href="/settings/labels">
|
||||
<a>labels</a>
|
||||
</Link>{' '}
|
||||
page allows you to edit all of your labels. From here you can create
|
||||
new labels, delete existing labels, or modify the color and
|
||||
description of a label.
|
||||
</p>
|
||||
</Box>
|
||||
<Box css={{ height: '120px' }} />
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
SpanBox,
|
||||
} from '../../components/elements/LayoutPrimitives'
|
||||
import { SettingsLayout } from '../../components/templates/SettingsLayout'
|
||||
import { Button } from '../../components/elements/Button'
|
||||
import Link from 'next/link'
|
||||
import { Copy, Plus } from 'phosphor-react'
|
||||
import { theme } from '../../components/tokens/stitches.config'
|
||||
|
||||
const AddEmailButton = () => {
|
||||
return (
|
||||
<Button
|
||||
style="ctaDarkYellow"
|
||||
css={{
|
||||
cursor: 'default',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Plus size={18} style={{ marginRight: '6.5px' }} />
|
||||
<SpanBox>Create a new email address</SpanBox>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const CopyButton = () => {
|
||||
return (
|
||||
<Button
|
||||
style="plainIcon"
|
||||
css={{
|
||||
pl: '2px',
|
||||
pr: '4px',
|
||||
cursor: 'default',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
<Copy color={theme.colors.grayTextContrast.toString()} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Newsletters(): JSX.Element {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Box
|
||||
css={{
|
||||
m: '42px',
|
||||
maxWidth: '640px',
|
||||
color: '$grayText',
|
||||
img: {
|
||||
maxWidth: '85%',
|
||||
},
|
||||
'@smDown': {
|
||||
m: '16px',
|
||||
maxWidth: '85%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h1>Read Newsletters in Omnivore</h1>
|
||||
<hr />
|
||||
<p>Omnivore supports newsletters from the following providers:</p>
|
||||
<ul>
|
||||
<li>
|
||||
Newsletters hosted on{' '}
|
||||
<a href="https://substack.com" target="_blank" rel="noreferrer">
|
||||
substack.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Newsletters hosted on{' '}
|
||||
<a href="https://www.beehiiv.com/" target="_blank" rel="noreferrer">
|
||||
beehiiv.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
The{' '}
|
||||
<a
|
||||
href="https://www.axios.com/newsletters"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Axios Daily
|
||||
</a>{' '}
|
||||
newsletters
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://golangweekly.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Golang Weekly
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.bloomberg.com/account/newsletters"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Bloomberg Newsletters
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
If there is a newsletter you would like to read in Omnivore, please
|
||||
let us know.
|
||||
</p>
|
||||
|
||||
<h2>Omnivore Email Addresses</h2>
|
||||
<p>
|
||||
Omnivore allows you to create unique email addresses for subscribing
|
||||
to newsletters. You can reuse one address for all your newsletters, or
|
||||
you can create a unique address for each.
|
||||
</p>
|
||||
<p>
|
||||
An Omnivore email address will receive email, detect whether the email
|
||||
is a newsletter, and add the newsletter content to your library. If
|
||||
the email does not appear to be a newsletter, it will be forwarded to
|
||||
the email address you used when you registered for Omnivore.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
There are multiple ways to add newsletters to your Omnivore library:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#updating">
|
||||
Updating your account email to an Omnivore email address
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#directly">
|
||||
Subscribe to the newsletter with an Omnivore email address
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#forwarding">
|
||||
Create a forwarding rule from your email account
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="updating">Updating your account email</h2>
|
||||
<p>
|
||||
If you want all your substack newsletters sent to Omnivore, you can
|
||||
login and change the address on{' '}
|
||||
<a href="https://substack.com/account/settings">your account page</a>{' '}
|
||||
in Substack.
|
||||
</p>
|
||||
|
||||
<h2 id="directly">Subscribing Directly</h2>
|
||||
<p>
|
||||
Create your first email address by clicking the <AddEmailButton />{' '}
|
||||
button on the <Link href="/settings/emails">emails page</Link>. Copy
|
||||
the email address to your clipboard using the <CopyButton />
|
||||
copy button, and enter that email address into an email subscription
|
||||
box.
|
||||
</p>
|
||||
|
||||
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
|
||||
<img
|
||||
src="/static/help/newsletter-email-signup.gif"
|
||||
alt="Animated image setting up an Omnivore Email Address"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<p>
|
||||
If you are already logged into Substack you might need to logout to
|
||||
use your new email address.
|
||||
</p>
|
||||
|
||||
<h2 id="forwarding">Create a Forwarding Rule</h2>
|
||||
|
||||
<p>
|
||||
If you are a Gmail user you can create a forwarding rule to send email
|
||||
from your regular account to your Omnivore email address. This is
|
||||
useful if you have an existing paid newsletter subscription and
|
||||
don't want to update your account email address.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
For free newsletters we recommend subscribing directly to the
|
||||
newsletter with your Omnivore email address instead of setting up
|
||||
forwarding rules.
|
||||
</p>
|
||||
|
||||
<p>Before you start:</p>
|
||||
<ul>
|
||||
<li>
|
||||
Create an Omnivore Email Address by clicking the <AddEmailButton />{' '}
|
||||
button on the <Link href="/settings/emails">emails page</Link>.
|
||||
</li>
|
||||
<li>
|
||||
Make a note of the Newsletter's sender email address. For
|
||||
example <code>omnivore@substack.com</code>.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>Create a forwarding rule:</p>
|
||||
<ul>
|
||||
<li>
|
||||
On a computer open your{' '}
|
||||
<Link href="https://mail.google.com/mail/u/0/#settings/fwdandpop">
|
||||
Gmail Forwarding Rules
|
||||
</Link>
|
||||
. If this link does not work: click on the Gear icon in the upper
|
||||
right corner of Gmail and select All Settings, then click the
|
||||
Forwarding and POP/IMAP tab.
|
||||
</li>
|
||||
<li>
|
||||
In the <b>Forwarding</b> section click{' '}
|
||||
<b>Add a forwarding address</b>.
|
||||
</li>
|
||||
<li>
|
||||
Enter your Omnivore Email Address (eg{' '}
|
||||
<code>username-sdfsd@inbox.omnivore.app</code>) and click Next.
|
||||
</li>
|
||||
<li>Click Proceed and OK</li>
|
||||
<li>
|
||||
Refresh the Omnivore Newsletter Emails page and you should see a
|
||||
code appear beside your address (eg 663421251). Copy this code to
|
||||
your clipboard (click the <CopyButton /> button).
|
||||
</li>
|
||||
<li>
|
||||
Return to your forwarding rules section and look for the confirm
|
||||
code text box. Enter the confirmation code you copied and click{' '}
|
||||
<b>Verify</b>.
|
||||
</li>
|
||||
<li>
|
||||
In the forwarding section of Gmail, Click on{' '}
|
||||
<b>Creating a Filter</b>
|
||||
</li>
|
||||
<li>
|
||||
Add the email address of your newsletter (eg omnivore@substack.app)
|
||||
in the <code>From</code> section.
|
||||
</li>
|
||||
<li>
|
||||
Click <code>Create Filter</code>
|
||||
</li>
|
||||
<li>
|
||||
Choose <b>Forward it to</b> and enter your Omnivore Email Address
|
||||
(eg <code>username-sdfsd@inbox.omnivore.app</code>)
|
||||
</li>
|
||||
<li>
|
||||
Click <code>Create Filter</code> at the bottom of the dialog.
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
<Box css={{ height: '120px' }} />
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Box, HStack } from '../../components/elements/LayoutPrimitives'
|
||||
import { PrimaryLayout } from '../../components/templates/PrimaryLayout'
|
||||
import { Button } from '../../components/elements/Button'
|
||||
import Link from 'next/link'
|
||||
import { SettingsLayout } from '../../components/templates/SettingsLayout'
|
||||
|
||||
export default function Colors(): JSX.Element {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Box
|
||||
css={{
|
||||
m: '42px',
|
||||
maxWidth: '640px',
|
||||
color: '$grayText',
|
||||
img: {
|
||||
maxWidth: '85%',
|
||||
},
|
||||
'@smDown': {
|
||||
m: '16px',
|
||||
maxWidth: '85%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h1>Saving Links to your Omnivore Library</h1>
|
||||
<hr />
|
||||
<h3>Learn how to save links to your Omnivore Library</h3>
|
||||
<p>
|
||||
Omnivore is a place to store everything you read. We keep it safe,
|
||||
organized, and easy to share.
|
||||
</p>
|
||||
<p>
|
||||
When you start using Omnivore, it is important to figure out the best
|
||||
way to save content to your library.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#savingfromyouriphone">Saving from your iPhone</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#savingfromyourandroiddevice">
|
||||
Saving from your Android Device
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#savingfromyourcomputer">Saving from your Computer</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#savingpdfswiththemacapp">Saving PDFs from your Mac</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h2 id="savingfromyouriphone">Saving from your iPhone</h2>
|
||||
<p>
|
||||
If you are using an iPhone or iPad, the best way to save links is by
|
||||
installing the iOS app. You can find the iOS app here:{' '}
|
||||
<a href="https://omnivore.app/install/ios">
|
||||
https://omnivore.app/install/ios
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
With the iOS Share extension installed, you can save links from Safari
|
||||
or any other app that supports sharing links.
|
||||
</p>
|
||||
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
|
||||
<img
|
||||
width={212}
|
||||
height={347}
|
||||
src="/static/help/share-module-ios.gif"
|
||||
alt="Animated image of iOS share extension share action"
|
||||
/>
|
||||
</HStack>
|
||||
<h2 id="savingfromyourandroiddevice">
|
||||
Saving from your Android Device
|
||||
</h2>
|
||||
<p>
|
||||
If you are using an Android device you can install the Omnivore
|
||||
Progressive Web App. After logging in to Omnivore in Chrome you should
|
||||
see an “Install Omnivore” option. Most Android versions
|
||||
display this at the bottom of the screen.
|
||||
</p>
|
||||
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
|
||||
<img
|
||||
src="/static/help/android-bottom-bar.png"
|
||||
alt="Save button shown on Android device"
|
||||
/>
|
||||
</HStack>
|
||||
<p>
|
||||
After installing Omnivore as a Progressive Web App it will be
|
||||
displayed in your Sharing Menu on Chrome.
|
||||
</p>
|
||||
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
|
||||
<img
|
||||
src="/static/help/android-share.png"
|
||||
alt="Android device with Omnivore progressive web app installed"
|
||||
/>
|
||||
</HStack>
|
||||
<h2 id="savingfromyourcomputer">Saving from your computer</h2>
|
||||
<p>
|
||||
If you are saving from a computer, you will need to install the
|
||||
Omnivore extension for the web browser(s) you use.
|
||||
</p>
|
||||
The browser extensions are available here:
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://omnivore.app/install/chrome">Chrome</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://omnivore.app/install/edge">Edge</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://omnivore.app/install/firefox">FireFox</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://omnivore.app/install/safari">Safari</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
With the browser extension(s) of your choice installed, you can tap
|
||||
the Omnivore button on any page to save your link.
|
||||
</p>
|
||||
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
|
||||
<img
|
||||
src="/static/help/share-extension.gif"
|
||||
alt="Animated image of browser plugin save action"
|
||||
/>
|
||||
</HStack>
|
||||
<h2 id="savingpdfswiththemacapp">Saving PDFs with the Mac App</h2>
|
||||
<p>
|
||||
<a href="https://omnivore.app/install/mac">
|
||||
https://omnivore.app/install/mac
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
With the MacOS App installed you can upload PDFs from your computer to
|
||||
your Omnivore library by right-clicking and sharing to Omnivore.
|
||||
</p>
|
||||
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
|
||||
<img
|
||||
src="/static/help/saving-pdfs-mac.png"
|
||||
alt="Animated image of macOS share extension link saving"
|
||||
/>
|
||||
</HStack>
|
||||
<p>
|
||||
You can enable sharing from Finder on the Mac in the Extensions
|
||||
section of System Preferences.
|
||||
</p>
|
||||
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
|
||||
<img
|
||||
src="/static/help/enable-sharing-on-mac.gif"
|
||||
alt="Animated image of enabling share extension for macOS Finder"
|
||||
/>
|
||||
</HStack>
|
||||
<HStack alignment="center" css={{ mb: '32px', width: '100%' }}>
|
||||
<Link passHref href="/home">
|
||||
<Button style="ctaDarkYellow">Start Reading</Button>
|
||||
</Link>
|
||||
</HStack>
|
||||
</Box>
|
||||
<Box css={{ height: '120px' }} />
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Box } from '../../components/elements/LayoutPrimitives'
|
||||
import { SettingsLayout } from '../../components/templates/SettingsLayout'
|
||||
|
||||
export default function Search(): JSX.Element {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Box
|
||||
css={{
|
||||
m: '42px',
|
||||
maxWidth: '640px',
|
||||
color: '$grayText',
|
||||
img: {
|
||||
maxWidth: '85%',
|
||||
},
|
||||
'@smDown': {
|
||||
m: '16px',
|
||||
maxWidth: '85%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h1>Search</h1>
|
||||
<hr />
|
||||
<p>
|
||||
Omnivore uses search to filter items in your library. You can use a
|
||||
simple keyword search or our advanced search syntax to find items.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#text">Searching for text</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#label">Filtering by label</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#in">Filtering by archive status</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#is">Filtering by read state</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#type">Filtering by type</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#has">Finding highlights</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#dates">Filtering by save/publish dates</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#sort">Sorting</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="text">Searching for text</h2>
|
||||
<p>
|
||||
Omnivore will perform full text search across library item's
|
||||
content, title, description, and site by default. You can search for
|
||||
specific terms by quoting your terms. By default all results that
|
||||
match your search will be returned in the order they were saved. To
|
||||
change your search to relevance use the <code>sort:score</code>{' '}
|
||||
parameter.
|
||||
</p>
|
||||
|
||||
<h2 id="label">Filtering by label</h2>
|
||||
<p>
|
||||
You can filter your search based on labels using AND and OR clauses.
|
||||
You can also negate a label search to find pages that do not have a
|
||||
certain label.
|
||||
</p>
|
||||
|
||||
<p>Some examples:</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
label:Newsletter finds all pages that have the label Newsletter
|
||||
</li>
|
||||
<li>
|
||||
label:Cooking,Fitness finds all your pages with either the Cooking
|
||||
or Fitness labels
|
||||
</li>
|
||||
<li>
|
||||
label:Newsletter label:Surfing finds all pages with both the
|
||||
Newsletter and Surfing labels
|
||||
</li>
|
||||
<li>
|
||||
label:Coding -label:News finds all pages with the Coding label that
|
||||
do not have the News label
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="in">Filtering by archive status</h2>
|
||||
<p>
|
||||
The <code>in:</code> filter is used to filter search by archive
|
||||
status. The options are:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<code>in:inbox</code> (the default): show unarchived items
|
||||
</li>
|
||||
<li>
|
||||
<code>in:archive</code>: show archived items
|
||||
</li>
|
||||
<li>
|
||||
<code>in:all</code>: Show all items regardless of archive state
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="is">Filtering by read state</h2>
|
||||
<p>
|
||||
The <code>is:</code> filter is used to filter search by read state.
|
||||
Note that in Omnivore 'read' means fully read, not just
|
||||
opened.
|
||||
</p>
|
||||
<p>
|
||||
The <code>is:</code> filter options are:{' '}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>is:read</code>: Show only items that are fully read
|
||||
</li>
|
||||
<li>
|
||||
<code>is:unread</code> (the default): show unread items
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="type">Filtering by type</h2>
|
||||
<p>
|
||||
The <code>type:</code> filter is used to filter search by type.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>type:article</code>: Show only articles
|
||||
</li>
|
||||
<li>
|
||||
<code>type:file</code>: Show only files
|
||||
</li>
|
||||
<li>
|
||||
<code>type:pdf</code>: Show only PDFs
|
||||
</li>
|
||||
<li>
|
||||
<code>type:highlights</code>: Show your highlights
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="has">Finding highlights</h2>
|
||||
<p>
|
||||
You can find your highlights by using the <code>type:highlights</code>{' '}
|
||||
filter or find saved items with highlights using the{' '}
|
||||
<code>has:highlights</code> filter.
|
||||
</p>
|
||||
|
||||
<h2 id="dates">Filtering by save/publish dates</h2>
|
||||
<p>
|
||||
You can filter your searches based on the time they were saved or
|
||||
published using the
|
||||
<code>saved:</code> and <code>published:</code> filters. These filters
|
||||
take two dates to create a date range. The <code>*</code> wildcard
|
||||
will accept any date.
|
||||
</p>
|
||||
<p>For Example:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>saved:2022-04-21..*</code> All items saved since 2022-04-21
|
||||
</li>
|
||||
<li>
|
||||
<code>published:2020-01-01..2022-02-02</code> All items published
|
||||
between 2020-01-01 and 2022-02-02
|
||||
</li>
|
||||
<li>
|
||||
<code>published:*..2020-01-01</code> All items published before
|
||||
2020-01-01
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="sort">Sorting</h2>
|
||||
<p>
|
||||
By default all search results in Omnivore are sorted by saved date.
|
||||
This puts the most recently saved items at the top of your library.
|
||||
You can use sort options to change the library order:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>sort:saved</code>: Sort by saved date
|
||||
</li>
|
||||
<li>
|
||||
<code>sort:updated</code>: Sort by time the item was updated, for
|
||||
example having a label or highlight added.
|
||||
</li>
|
||||
<li>
|
||||
<code>sort:score</code>: Sort by query term relevance.
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
<Box css={{ height: '120px' }} />
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ export default function EmailsPage(): JSX.Element {
|
|||
<>
|
||||
<SettingsTable
|
||||
pageId="settings-emails-tag"
|
||||
pageInfoLink="/help/newsletters"
|
||||
pageInfoLink="https://docs.omnivore.app/using/inbox.html"
|
||||
headerTitle="Address"
|
||||
createTitle="Create a new email address"
|
||||
createAction={createEmail}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { ConfirmationModal } from '../../../components/patterns/ConfirmationModa
|
|||
import {
|
||||
EmptySettingsRow,
|
||||
SettingsTable,
|
||||
SettingsTableRow
|
||||
SettingsTableRow,
|
||||
} from '../../../components/templates/settings/SettingsTable'
|
||||
import { theme } from '../../../components/tokens/stitches.config'
|
||||
import { formattedDateTime } from '../../../lib/dateFormatting'
|
||||
|
|
@ -17,7 +17,7 @@ import { updateSubscriptionMutation } from '../../../lib/networking/mutations/up
|
|||
import {
|
||||
SubscriptionStatus,
|
||||
SubscriptionType,
|
||||
useGetSubscriptionsQuery
|
||||
useGetSubscriptionsQuery,
|
||||
} from '../../../lib/networking/queries/useGetSubscriptionsQuery'
|
||||
import { applyStoredTheme } from '../../../lib/themeUpdater'
|
||||
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
|
||||
|
|
@ -90,17 +90,15 @@ export default function Rss(): JSX.Element {
|
|||
return (
|
||||
<SettingsTable
|
||||
pageId={'feeds'}
|
||||
pageInfoLink={''} // TODO: https://docs.omnivore.app/settings/feeds.html
|
||||
headerTitle={'Subscribed feeds'}
|
||||
createTitle={'Add feed'}
|
||||
pageInfoLink="https://docs.omnivore.app/settings/feeds.html"
|
||||
headerTitle="Subscribed feeds"
|
||||
createTitle="Add feed"
|
||||
createAction={() => {
|
||||
router.push('/settings/feeds/add')
|
||||
}}
|
||||
>
|
||||
{subscriptions.length === 0 ? (
|
||||
<EmptySettingsRow
|
||||
text={isValidating ? '-' : 'No feeds subscribed'}
|
||||
/>
|
||||
<EmptySettingsRow text={isValidating ? '-' : 'No feeds subscribed'} />
|
||||
) : (
|
||||
subscriptions.map((subscription, i) => {
|
||||
return (
|
||||
|
|
@ -218,9 +216,7 @@ export default function Rss(): JSX.Element {
|
|||
|
||||
{onDeleteId && (
|
||||
<ConfirmationModal
|
||||
message={
|
||||
'Feed will be unsubscribed. This action cannot be undone.'
|
||||
}
|
||||
message={'Feed will be unsubscribed. This action cannot be undone.'}
|
||||
onAccept={async () => {
|
||||
await onDelete(onDeleteId)
|
||||
setOnDeleteId('')
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ export default function LabelsPage(): JSX.Element {
|
|||
<Box>
|
||||
<StyledText style="fixedHeadline">Labels </StyledText>
|
||||
</Box>
|
||||
<InfoLink href="/help/labels" />
|
||||
<InfoLink href="https://docs.omnivore.app/using/organizing.html#labels" />
|
||||
<Box
|
||||
css={{
|
||||
display: 'flex',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { applyStoredTheme } from '../../lib/themeUpdater'
|
||||
import { ConfirmationModal } from '../../components/patterns/ConfirmationModal'
|
||||
import { useGetSubscriptionsQuery } from '../../lib/networking/queries/useGetSubscriptionsQuery'
|
||||
import {
|
||||
Subscription,
|
||||
SubscriptionType,
|
||||
useGetSubscriptionsQuery,
|
||||
} from '../../lib/networking/queries/useGetSubscriptionsQuery'
|
||||
import { unsubscribeMutation } from '../../lib/networking/mutations/unsubscribeMutation'
|
||||
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
|
||||
import {
|
||||
|
|
@ -15,14 +19,13 @@ import { formattedShortDate } from '../../lib/dateFormatting'
|
|||
|
||||
export default function SubscriptionsPage(): JSX.Element {
|
||||
const { subscriptions, revalidate, isValidating } = useGetSubscriptionsQuery()
|
||||
const [confirmUnsubscribeName, setConfirmUnsubscribeName] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const [confirmUnsubscribeSubscription, setConfirmUnsubscribeSubscription] =
|
||||
useState<Subscription | null>(null)
|
||||
|
||||
applyStoredTheme(false)
|
||||
|
||||
async function onUnsubscribe(name: string): Promise<void> {
|
||||
const result = await unsubscribeMutation(name)
|
||||
async function onUnsubscribe(subscription: Subscription): Promise<void> {
|
||||
const result = await unsubscribeMutation(subscription.name, subscription.id)
|
||||
if (result) {
|
||||
showSuccessToast('Unsubscribed', { position: 'bottom-right' })
|
||||
} else {
|
||||
|
|
@ -41,7 +44,7 @@ export default function SubscriptionsPage(): JSX.Element {
|
|||
return (
|
||||
<SettingsTable
|
||||
pageId="settings-subscriptions-tag"
|
||||
pageInfoLink="/help/newsletters"
|
||||
pageInfoLink="https://docs.omnivore.app/using/inbox.html"
|
||||
headerTitle="Subscriptions"
|
||||
>
|
||||
<>
|
||||
|
|
@ -52,13 +55,14 @@ export default function SubscriptionsPage(): JSX.Element {
|
|||
key={subscription.id}
|
||||
title={subscription.name}
|
||||
isLast={i === sortedSubscriptions.length - 1}
|
||||
onDelete={() => setConfirmUnsubscribeName(subscription.name)}
|
||||
onDelete={() => setConfirmUnsubscribeSubscription(subscription)}
|
||||
deleteTitle="Unsubscribe"
|
||||
sublineElement={
|
||||
<StyledText
|
||||
css={{
|
||||
my: '5px',
|
||||
fontSize: '11px',
|
||||
|
||||
a: {
|
||||
color: '$omnivoreCtaYellow',
|
||||
},
|
||||
|
|
@ -66,12 +70,25 @@ export default function SubscriptionsPage(): JSX.Element {
|
|||
>
|
||||
{`Last received ${formattedShortDate(
|
||||
subscription.updatedAt
|
||||
)} at `}
|
||||
<Link
|
||||
href={`/settings/emails?address=${subscription.newsletterEmail}`}
|
||||
>
|
||||
{subscription.newsletterEmail}
|
||||
</Link>
|
||||
)}`}
|
||||
{subscription.newsletterEmail && (
|
||||
<>
|
||||
{' '}
|
||||
at{' '}
|
||||
<Link
|
||||
href={`/settings/emails?address=${subscription.newsletterEmail}`}
|
||||
>
|
||||
{subscription.newsletterEmail}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{subscription.type == SubscriptionType.RSS &&
|
||||
subscription.url && (
|
||||
<>
|
||||
{' '}
|
||||
via <Link href={subscription.url}>RSS</Link>
|
||||
</>
|
||||
)}
|
||||
</StyledText>
|
||||
}
|
||||
/>
|
||||
|
|
@ -83,16 +100,18 @@ export default function SubscriptionsPage(): JSX.Element {
|
|||
/>
|
||||
)}
|
||||
|
||||
{confirmUnsubscribeName ? (
|
||||
{confirmUnsubscribeSubscription ? (
|
||||
<ConfirmationModal
|
||||
message={
|
||||
'Are you sure? You will stop receiving newsletters from this subscription.'
|
||||
confirmUnsubscribeSubscription.type == SubscriptionType.NEWSLETTER
|
||||
? 'Are you sure? You will stop receiving newsletters from this subscription.'
|
||||
: 'Are you sure? You will stop receiving updates from this feed.'
|
||||
}
|
||||
onAccept={async () => {
|
||||
await onUnsubscribe(confirmUnsubscribeName)
|
||||
setConfirmUnsubscribeName(null)
|
||||
await onUnsubscribe(confirmUnsubscribeSubscription)
|
||||
setConfirmUnsubscribeSubscription(null)
|
||||
}}
|
||||
onOpenChange={() => setConfirmUnsubscribeName(null)}
|
||||
onOpenChange={() => setConfirmUnsubscribeSubscription(null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export default function Webhooks(): JSX.Element {
|
|||
const [url, setUrl] = useState('')
|
||||
const eventTypeOptions: EventTypeOption[] = [
|
||||
{ label: 'PAGE_CREATED', value: 'PAGE_CREATED' },
|
||||
{ label: 'PAGE_UPDATED', value: 'PAGE_UPDATED' },
|
||||
{ label: 'HIGHLIGHT_CREATED', value: 'HIGHLIGHT_CREATED' },
|
||||
{ label: 'LABEL_ADDED', value: 'LABEL_CREATED' },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -12,10 +12,16 @@ export default {
|
|||
},
|
||||
} as ComponentMeta<typeof EmptyLibrary>
|
||||
|
||||
export const EmptyLibraryStory: ComponentStory<typeof EmptyLibrary> = (args: any) => {
|
||||
export const EmptyLibraryStory: ComponentStory<typeof EmptyLibrary> = (
|
||||
args: any
|
||||
) => {
|
||||
return (
|
||||
<EmptyLibrary onAddLinkClicked={() => {
|
||||
console.log('onAddLinkClicked')
|
||||
}} />
|
||||
<EmptyLibrary
|
||||
layoutType="GRID_LAYOUT"
|
||||
searchTerm=""
|
||||
onAddLinkClicked={() => {
|
||||
console.log('onAddLinkClicked')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue