mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #464 from omnivore-app/fix/empty-state
Display empty state when no library items are found
This commit is contained in:
commit
330f5c07c7
7 changed files with 5536 additions and 300 deletions
36
packages/web/components/templates/homeFeed/EmptyLibrary.tsx
Normal file
36
packages/web/components/templates/homeFeed/EmptyLibrary.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
|
||||
|
||||
// There aren't any discussions.
|
||||
// You can open a new discussion to ask questions about this repository or get help.
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Book } from 'phosphor-react'
|
||||
import { LibraryItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { Box, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { LinkedItemCardAction } from '../../patterns/LibraryCards/CardTypes'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
|
||||
type EmptyLibraryProps = {
|
||||
onAddLinkClicked: () => void
|
||||
}
|
||||
|
||||
export function EmptyLibrary(props: EmptyLibraryProps): JSX.Element {
|
||||
return (
|
||||
<VStack alignment="center" distribution="center" css={{ color: '$grayTextContrast', textAlign: 'center', paddingTop: '88px' }}>
|
||||
<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 <Link href="/help/search">advanced search</Link>.
|
||||
</StyledText>
|
||||
|
||||
<Button style="ctaDarkYellow" onClick={() => { props.onAddLinkClicked() }}>
|
||||
Add Link
|
||||
</Button>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
|
@ -38,6 +38,9 @@ import { ConfirmationModal } from '../../patterns/ConfirmationModal'
|
|||
import { SetLabelsModal } from '../article/SetLabelsModal'
|
||||
import { Label } from '../../../lib/networking/fragments/labelFragment'
|
||||
import { isVipUser } from '../../../lib/featureFlag'
|
||||
import { EmptyLibrary } from './EmptyLibrary'
|
||||
import TopBarProgress from 'react-topbar-progress-indicator'
|
||||
|
||||
|
||||
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
|
||||
|
||||
|
|
@ -47,10 +50,10 @@ export type HomeFeedContainerProps = {
|
|||
|
||||
const SAVED_SEARCHES: Record<string,string> = {
|
||||
'Inbox': '',
|
||||
'All': 'in:all',
|
||||
'Read Later': `in:inbox -label:Newsletter`,
|
||||
'Highlighted': `in:inbox has:highlights`,
|
||||
'Today': `in:inbox saved:${new Date(new Date().getTime() - (24 * 3600000)).toISOString().split('T')[0]}..*`,
|
||||
'Newsletter': `in:inbox label:Newsletter`,
|
||||
'Non-Newsletter': `in:inbox -label:Newsletter`,
|
||||
'Newsletters': `in:inbox label:Newsletter`,
|
||||
}
|
||||
|
||||
export function HomeFeedContainer(props: HomeFeedContainerProps): JSX.Element {
|
||||
|
|
@ -532,6 +535,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
|
|||
}}
|
||||
>
|
||||
<Toaster />
|
||||
{(props.isValidating && props.items.length == 0 && <TopBarProgress />)}
|
||||
<HStack alignment="center" distribution="start" css={{ width: '100%' }}>
|
||||
<StyledText
|
||||
style="subHeadline"
|
||||
|
|
@ -596,7 +600,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
|
|||
const searchQuery = SAVED_SEARCHES[key]
|
||||
const style = searchQuery === props.searchTerm || (!props.searchTerm && !searchQuery) ? 'ctaDarkYellow' : 'ctaLightGray'
|
||||
return (
|
||||
<Button key={key} style={style} onClick={() => { props.applySearchQuery(searchQuery)}} css={{ p: '10px 12px', borderRadius: '6px', whiteSpace: 'nowrap' }}>
|
||||
<Button key={key} style={style} onClick={() => { props.applySearchQuery(searchQuery)}} css={{ p: '10px 12px', height: '37.5px', borderRadius: '6px', whiteSpace: 'nowrap' }}>
|
||||
{key}
|
||||
</Button>
|
||||
)
|
||||
|
|
@ -604,95 +608,97 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
|
|||
}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
ref={props.gridContainerRef}
|
||||
css={{
|
||||
py: '$3',
|
||||
display: 'grid',
|
||||
width: '100%',
|
||||
gridAutoRows: 'auto',
|
||||
borderRadius: '8px',
|
||||
gridGap: layout == 'LIST_LAYOUT' ? '0' : '$3',
|
||||
marginTop: layout == 'LIST_LAYOUT' ? '21px' : '0',
|
||||
marginBottom: '0px',
|
||||
paddingTop: layout == 'LIST_LAYOUT' ? '0' : '21px',
|
||||
paddingBottom: layout == 'LIST_LAYOUT' ? '0px' : '21px',
|
||||
overflow: 'hidden',
|
||||
'@smDown': {
|
||||
border: 'unset',
|
||||
width: layout == 'LIST_LAYOUT' ? '100vw' : undefined,
|
||||
margin: layout == 'LIST_LAYOUT' ? '16px -16px' : undefined,
|
||||
borderRadius: layout == 'LIST_LAYOUT' ? 0 : undefined,
|
||||
},
|
||||
'@md': {
|
||||
gridTemplateColumns: layout == 'LIST_LAYOUT' ? 'none' : '1fr 1fr',
|
||||
},
|
||||
'@lg': {
|
||||
gridTemplateColumns:
|
||||
layout == 'LIST_LAYOUT' ? 'none' : 'repeat(3, 1fr)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.items.map((linkedItem) => (
|
||||
<Box
|
||||
className="linkedItemCard"
|
||||
id={linkedItem.node.id}
|
||||
tabIndex={0}
|
||||
key={linkedItem.node.id}
|
||||
css={{
|
||||
width: '100%',
|
||||
'&> div': {
|
||||
bg: '$grayBg',
|
||||
},
|
||||
'&:focus': {
|
||||
'> div': {
|
||||
bg: '$grayBgActive',
|
||||
{!props.isValidating && props.items.length == 0 ? <EmptyLibrary onAddLinkClicked={() => { props.setShowAddLinkModal(true) }} /> : (
|
||||
<Box
|
||||
ref={props.gridContainerRef}
|
||||
css={{
|
||||
py: '$3',
|
||||
display: 'grid',
|
||||
width: '100%',
|
||||
gridAutoRows: 'auto',
|
||||
borderRadius: '8px',
|
||||
gridGap: layout == 'LIST_LAYOUT' ? '0' : '$3',
|
||||
marginTop: layout == 'LIST_LAYOUT' ? '21px' : '0',
|
||||
marginBottom: '0px',
|
||||
paddingTop: layout == 'LIST_LAYOUT' ? '0' : '21px',
|
||||
paddingBottom: layout == 'LIST_LAYOUT' ? '0px' : '21px',
|
||||
overflow: 'hidden',
|
||||
'@smDown': {
|
||||
border: 'unset',
|
||||
width: layout == 'LIST_LAYOUT' ? '100vw' : undefined,
|
||||
margin: layout == 'LIST_LAYOUT' ? '16px -16px' : undefined,
|
||||
borderRadius: layout == 'LIST_LAYOUT' ? 0 : undefined,
|
||||
},
|
||||
'@md': {
|
||||
gridTemplateColumns: layout == 'LIST_LAYOUT' ? 'none' : '1fr 1fr',
|
||||
},
|
||||
'@lg': {
|
||||
gridTemplateColumns:
|
||||
layout == 'LIST_LAYOUT' ? 'none' : 'repeat(3, 1fr)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.items.map((linkedItem) => (
|
||||
<Box
|
||||
className="linkedItemCard"
|
||||
id={linkedItem.node.id}
|
||||
tabIndex={0}
|
||||
key={linkedItem.node.id}
|
||||
css={{
|
||||
width: '100%',
|
||||
'&> div': {
|
||||
bg: '$grayBg',
|
||||
},
|
||||
},
|
||||
'&:hover': {
|
||||
'> div': {
|
||||
bg: '$grayBgActive',
|
||||
'&:focus': {
|
||||
'> div': {
|
||||
bg: '$grayBgActive',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{viewerData?.me && (
|
||||
<LinkedItemCard
|
||||
layout={layout}
|
||||
item={linkedItem.node}
|
||||
viewer={viewerData.me}
|
||||
handleAction={(action: LinkedItemCardAction) => {
|
||||
if (action === 'delete') {
|
||||
setShowRemoveLinkConfirmation(true)
|
||||
setLinkToRemove(linkedItem)
|
||||
} else {
|
||||
props.actionHandler(action, linkedItem)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<HStack
|
||||
distribution="center"
|
||||
css={{ width: '100%', mt: '$2', mb: '$4' }}
|
||||
>
|
||||
{props.hasMore ? (
|
||||
<Button
|
||||
style="ctaGray"
|
||||
css={{
|
||||
cursor: props.isValidating ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={props.loadMore}
|
||||
disabled={props.isValidating}
|
||||
>
|
||||
{props.isValidating ? 'Loading' : 'Load More'}
|
||||
</Button>
|
||||
) : (
|
||||
<StyledText style="caption"></StyledText>
|
||||
'&:hover': {
|
||||
'> div': {
|
||||
bg: '$grayBgActive',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{viewerData?.me && (
|
||||
<LinkedItemCard
|
||||
layout={layout}
|
||||
item={linkedItem.node}
|
||||
viewer={viewerData.me}
|
||||
handleAction={(action: LinkedItemCardAction) => {
|
||||
if (action === 'delete') {
|
||||
setShowRemoveLinkConfirmation(true)
|
||||
setLinkToRemove(linkedItem)
|
||||
} else {
|
||||
props.actionHandler(action, linkedItem)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack
|
||||
distribution="center"
|
||||
css={{ width: '100%', mt: '$2', mb: '$4' }}
|
||||
>
|
||||
{props.hasMore ? (
|
||||
<Button
|
||||
style="ctaGray"
|
||||
css={{
|
||||
cursor: props.isValidating ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={props.loadMore}
|
||||
disabled={props.isValidating}
|
||||
>
|
||||
{props.isValidating ? 'Loading' : 'Load More'}
|
||||
</Button>
|
||||
) : (
|
||||
<StyledText style="caption"></StyledText>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
{props.showAddLinkModal && (
|
||||
<AddLinkModal onOpenChange={() => props.setShowAddLinkModal(false)} />
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"react-hot-toast": "^2.1.1",
|
||||
"react-intl": "^5.20.12",
|
||||
"react-loading-skeleton": "^3.0.2",
|
||||
"react-topbar-progress-indicator": "^4.1.1",
|
||||
"react-twitter-widgets": "^1.10.0",
|
||||
"swr": "^1.0.1",
|
||||
"uuid": "^8.3.2"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ import { useEffect, useState } from 'react'
|
|||
import { Analytics, AnalyticsBrowser } from '@segment/analytics-next'
|
||||
import { segmentApiKey } from '../lib/appConfig'
|
||||
import { TooltipProvider } from '../components/elements/Tooltip'
|
||||
import TopBarProgress from 'react-topbar-progress-indicator'
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
"0": '#FFD234',
|
||||
"1.0": '#FFD234',
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
function OmnivoreApp({ Component, pageProps }: AppProps): JSX.Element {
|
||||
const router = useRouter()
|
||||
|
|
|
|||
140
packages/web/pages/help/search.tsx
Normal file
140
packages/web/pages/help/search.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Box, HStack, SpanBox } from '../../components/elements/LayoutPrimitives'
|
||||
import { PrimaryLayout } from '../../components/templates/PrimaryLayout'
|
||||
import { Button } from '../../components/elements/Button'
|
||||
import Link from 'next/link'
|
||||
import { Copy, Plus } from 'phosphor-react'
|
||||
import { theme } from '../../components/tokens/stitches.config'
|
||||
|
||||
export default function Search(): JSX.Element {
|
||||
return (
|
||||
<PrimaryLayout
|
||||
pageMetaDataProps={{
|
||||
title: 'Search',
|
||||
path: '/help/search',
|
||||
}}
|
||||
pageTestId="help-search-tag"
|
||||
>
|
||||
<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 simple
|
||||
keyword search to find items or our advanced search syntax.
|
||||
</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> or <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' }} />
|
||||
</PrimaryLayout>
|
||||
)
|
||||
}
|
||||
21
packages/web/stories/EmptyLibrary.stories.tsx
Normal file
21
packages/web/stories/EmptyLibrary.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import { EmptyLibrary } from '../components/templates/homeFeed/EmptyLibrary'
|
||||
|
||||
export default {
|
||||
title: 'Components/EmptyLibraryStory',
|
||||
component: EmptyLibrary,
|
||||
argTypes: {
|
||||
position: {
|
||||
description: 'The empty library component',
|
||||
control: { type: 'select' },
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof EmptyLibrary>
|
||||
|
||||
export const EmptyLibraryStory: ComponentStory<typeof EmptyLibrary> = (args: any) => {
|
||||
return (
|
||||
<EmptyLibrary onAddLinkClicked={() => {
|
||||
console.log('onAddLinkClicked')
|
||||
}} />
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue