Merge pull request #396 from omnivore-app/feature/search-highlights-web
Feature/search highlights web
This commit is contained in:
@ -177,3 +177,7 @@ export const StyledImg = styled('img', {
|
||||
export const StyledAnchor = styled('a', {
|
||||
textDecoration: 'none'
|
||||
})
|
||||
|
||||
export const StyledMark = styled('mark', {
|
||||
|
||||
})
|
||||
@ -8,6 +8,7 @@ type HighlightViewProps = {
|
||||
highlight: Highlight
|
||||
author?: string
|
||||
title?: string
|
||||
scrollToHighlight?: (arg: string) => void;
|
||||
}
|
||||
|
||||
export function HighlightView(props: HighlightViewProps): JSX.Element {
|
||||
@ -22,6 +23,7 @@ export function HighlightView(props: HighlightViewProps): JSX.Element {
|
||||
fontSize: '18px',
|
||||
lineHeight: '27px',
|
||||
color: '$textDefault',
|
||||
cursor: 'pointer',
|
||||
})
|
||||
|
||||
return (
|
||||
@ -31,7 +33,11 @@ export function HighlightView(props: HighlightViewProps): JSX.Element {
|
||||
<StyledText style='shareHighlightModalAnnotation'>{annotation}</StyledText>
|
||||
</Box>)
|
||||
}
|
||||
<StyledQuote>
|
||||
<StyledQuote onClick={() => {
|
||||
if (props.scrollToHighlight) {
|
||||
props.scrollToHighlight(props.highlight.id)
|
||||
}
|
||||
}}>
|
||||
{props.highlight.prefix}
|
||||
<SpanBox css={{ bg: '$highlightBackground', p: '1px', borderRadius: '2px', }}>
|
||||
{lines.map((line: string, index: number) => (
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
import { styled } from '@stitches/react'
|
||||
import { VStack, HStack } from '../../elements/LayoutPrimitives'
|
||||
import { StyledMark, StyledText } from '../../elements/StyledText'
|
||||
import { LinkedItemCardAction, LinkedItemCardProps } from './CardTypes'
|
||||
|
||||
export interface HighlightItemCardProps
|
||||
extends Pick<LinkedItemCardProps, 'item'> {
|
||||
handleAction: (action: LinkedItemCardAction) => void
|
||||
}
|
||||
|
||||
export const PreviewImage = styled('img', {
|
||||
objectFit: 'cover',
|
||||
cursor: 'pointer',
|
||||
})
|
||||
|
||||
export function HighlightItemCard(props: HighlightItemCardProps): JSX.Element {
|
||||
return (
|
||||
<VStack
|
||||
css={{
|
||||
p: '$2',
|
||||
height: '100%',
|
||||
maxWidth: '498px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
wordBreak: 'break-word',
|
||||
overflow: 'clip',
|
||||
border: '1px solid $grayBorder',
|
||||
boxShadow: '0px 3px 11px rgba(32, 31, 29, 0.04)',
|
||||
bg: '$grayBg',
|
||||
'&:focus': {
|
||||
bg: '$grayBgActive',
|
||||
},
|
||||
'&:hover': {
|
||||
bg: '$grayBgActive',
|
||||
},
|
||||
}}
|
||||
alignment="start"
|
||||
distribution="start"
|
||||
onClick={() => {
|
||||
props.handleAction('showDetail')
|
||||
}}
|
||||
>
|
||||
<StyledText
|
||||
css={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
<StyledMark
|
||||
css={{
|
||||
background: '$highlightBackground',
|
||||
color: '$highlightText',
|
||||
}}
|
||||
>
|
||||
{props.item.quote}
|
||||
</StyledMark>
|
||||
</StyledText>
|
||||
<HStack
|
||||
css={{
|
||||
marginTop: 'auto',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{props.item.image && (
|
||||
<PreviewImage
|
||||
src={props.item.image}
|
||||
alt="Preview Image"
|
||||
width={16}
|
||||
height={16}
|
||||
css={{ borderRadius: '50%' }}
|
||||
onError={(e) => {
|
||||
;(e.target as HTMLElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<StyledText
|
||||
css={{
|
||||
marginLeft: '$2',
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{props.item.title
|
||||
.substring(0, 50)
|
||||
.concat(props.item.title.length > 50 ? '...' : '')}
|
||||
</StyledText>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { GridLinkedItemCard } from './GridLinkedItemCard'
|
||||
import { ListLinkedItemCard } from './ListLinkedItemCard'
|
||||
import type { LinkedItemCardProps } from './CardTypes'
|
||||
import { HighlightItemCard } from './HighlightItemCard'
|
||||
import { PageType } from '../../../lib/networking/fragments/articleFragment'
|
||||
|
||||
const siteName = (originalArticleUrl: string, itemUrl: string): string => {
|
||||
try {
|
||||
@ -15,6 +17,9 @@ const siteName = (originalArticleUrl: string, itemUrl: string): string => {
|
||||
export function LinkedItemCard(props: LinkedItemCardProps): JSX.Element {
|
||||
const originText = siteName(props.item.originalArticleUrl, props.item.url)
|
||||
|
||||
if (props.item.pageType === PageType.HIGHLIGHTS) {
|
||||
return <HighlightItemCard {...props} />
|
||||
}
|
||||
if (props.layout == 'LIST_LAYOUT') {
|
||||
return <ListLinkedItemCard {...props} originText={originText} />
|
||||
} else {
|
||||
|
||||
@ -21,8 +21,10 @@ import { ArticleMutations } from '../../../lib/articleActions'
|
||||
export type ArticleProps = {
|
||||
articleId: string
|
||||
content: string
|
||||
highlightReady: boolean
|
||||
initialAnchorIndex: number
|
||||
initialReadingProgress?: number
|
||||
highlightHref: MutableRefObject<string | null>
|
||||
scrollElementRef: MutableRefObject<HTMLDivElement | null>
|
||||
articleMutations: ArticleMutations
|
||||
}
|
||||
@ -139,50 +141,53 @@ export function Article(props: ArticleProps): JSX.Element {
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldScrollToInitialPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
setShouldScrollToInitialPosition(false)
|
||||
|
||||
if (props.initialReadingProgress && props.initialReadingProgress >= 98) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchorElement = document.querySelector(
|
||||
`[data-omnivore-anchor-idx='${props.initialAnchorIndex.toString()}']`
|
||||
)
|
||||
|
||||
if (anchorElement) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const calculateOffset = (obj: any): number => {
|
||||
let offset = 0
|
||||
if (obj.offsetParent) {
|
||||
do {
|
||||
offset += obj.offsetTop
|
||||
} while ((obj = obj.offsetParent))
|
||||
return offset
|
||||
}
|
||||
|
||||
return 0
|
||||
if (props.highlightReady) {
|
||||
if (!shouldScrollToInitialPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.scrollElementRef.current) {
|
||||
props.scrollElementRef.current?.scroll(
|
||||
0,
|
||||
calculateOffset(anchorElement)
|
||||
)
|
||||
} else {
|
||||
window.document.documentElement.scroll(
|
||||
0,
|
||||
calculateOffset(anchorElement)
|
||||
)
|
||||
setShouldScrollToInitialPosition(false)
|
||||
|
||||
if (props.initialReadingProgress && props.initialReadingProgress >= 98) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchorElement = props.highlightHref.current
|
||||
? document.querySelector(
|
||||
`[omnivore-highlight-id="${props.highlightHref.current}"]`
|
||||
)
|
||||
: document.querySelector(
|
||||
`[data-omnivore-anchor-idx='${props.initialAnchorIndex.toString()}']`
|
||||
)
|
||||
|
||||
if (anchorElement) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const calculateOffset = (obj: any): number => {
|
||||
let offset = 0
|
||||
if (obj.offsetParent) {
|
||||
do {
|
||||
offset += obj.offsetTop
|
||||
} while ((obj = obj.offsetParent))
|
||||
return offset
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const calculatedOffset = calculateOffset(anchorElement)
|
||||
|
||||
if (props.scrollElementRef.current) {
|
||||
props.scrollElementRef.current?.scroll(0, calculatedOffset - 100)
|
||||
} else {
|
||||
window.document.documentElement.scroll(0, calculatedOffset - 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
props.highlightReady,
|
||||
props.scrollElementRef,
|
||||
props.initialAnchorIndex,
|
||||
props.initialReadingProgress,
|
||||
props.scrollElementRef,
|
||||
shouldScrollToInitialPosition,
|
||||
])
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { ArticleSubtitle } from './../../patterns/ArticleSubtitle'
|
||||
import { theme, ThemeId } from './../../tokens/stitches.config'
|
||||
import { HighlightsLayer } from '../../templates/article/HighlightsLayer'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { MutableRefObject, useEffect, useState } from 'react'
|
||||
import { MutableRefObject, useEffect, useState, useRef } from 'react'
|
||||
import { ReportIssuesModal } from './ReportIssuesModal'
|
||||
import { reportIssueMutation } from '../../../lib/networking/mutations/reportIssueMutation'
|
||||
import { ArticleHeaderToolbar } from './ArticleHeaderToolbar'
|
||||
@ -15,6 +15,7 @@ import { updateThemeLocally } from '../../../lib/themeUpdater'
|
||||
import { ArticleMutations } from '../../../lib/articleActions'
|
||||
import { LabelChip } from '../../elements/LabelChip'
|
||||
import { Label } from '../../../lib/networking/fragments/labelFragment'
|
||||
import { HighlightLocation, makeHighlightStartEndOffset } from '../../../lib/highlights/highlightGenerator'
|
||||
|
||||
type ArticleContainerProps = {
|
||||
article: ArticleAttributes
|
||||
@ -36,6 +37,11 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showReportIssuesModal, setShowReportIssuesModal] = useState(false)
|
||||
const [fontSize, setFontSize] = useState(props.fontSize ?? 20)
|
||||
const highlightHref = useRef(window.location.hash ? window.location.hash.split('#')[1] : null)
|
||||
const [highlightReady, setHighlightReady] = useState(false)
|
||||
const [highlightLocations, setHighlightLocations] = useState<
|
||||
HighlightLocation[]
|
||||
>([])
|
||||
|
||||
const updateFontSize = async (newFontSize: number) => {
|
||||
if (fontSize !== newFontSize) {
|
||||
@ -48,6 +54,21 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
updateFontSize(props.fontSize ?? 20)
|
||||
}, [props.fontSize])
|
||||
|
||||
// Load the highlights
|
||||
useEffect(() => {
|
||||
const res: HighlightLocation[] = []
|
||||
props.article.highlights.forEach((highlight) => {
|
||||
try {
|
||||
const offset = makeHighlightStartEndOffset(highlight)
|
||||
res.push(offset)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
setHighlightLocations(res)
|
||||
setHighlightReady(true)
|
||||
}, [props.article.highlights, setHighlightLocations])
|
||||
|
||||
// Listen for font size and color mode change events sent from host apps (ios, macos...)
|
||||
useEffect(() => {
|
||||
const increaseFontSize = async () => {
|
||||
@ -158,6 +179,8 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
)}
|
||||
</VStack>
|
||||
<Article
|
||||
highlightReady={highlightReady}
|
||||
highlightHref={highlightHref}
|
||||
articleId={props.article.id}
|
||||
content={props.article.content}
|
||||
initialAnchorIndex={props.article.readingProgressAnchorIndex}
|
||||
@ -182,6 +205,7 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
<Box css={{ height: '100px' }} />
|
||||
</Box>
|
||||
<HighlightsLayer
|
||||
highlightLocations={highlightLocations}
|
||||
highlights={props.article.highlights}
|
||||
articleTitle={props.article.title}
|
||||
articleAuthor={props.article.author ?? ''}
|
||||
|
||||
@ -29,6 +29,7 @@ type HighlightsLayerProps = {
|
||||
highlightsBaseURL: string
|
||||
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
|
||||
articleMutations: ArticleMutations
|
||||
highlightLocations: HighlightLocation[]
|
||||
}
|
||||
|
||||
type HighlightModalAction = 'none' | 'addComment' | 'share'
|
||||
@ -49,9 +50,6 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
const [highlightModalAction, setHighlightModalAction] =
|
||||
useState<HighlightActionProps>({ highlightModalAction: 'none' })
|
||||
|
||||
const [highlightLocations, setHighlightLocations] = useState<
|
||||
HighlightLocation[]
|
||||
>([])
|
||||
const focusedHighlightMousePos = useRef({ pageX: 0, pageY: 0 })
|
||||
|
||||
const [focusedHighlight, setFocusedHighlight] = useState<
|
||||
@ -59,26 +57,12 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
>(undefined)
|
||||
|
||||
const [selectionData, setSelectionData] = useSelection(
|
||||
highlightLocations,
|
||||
props.highlightLocations,
|
||||
false //noteModal.open,
|
||||
)
|
||||
|
||||
const canShareNative = useCanShareNative()
|
||||
|
||||
// Load the highlights
|
||||
useEffect(() => {
|
||||
const res: HighlightLocation[] = []
|
||||
highlights.forEach((highlight) => {
|
||||
try {
|
||||
const offset = makeHighlightStartEndOffset(highlight)
|
||||
res.push(offset)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
setHighlightLocations(res)
|
||||
}, [highlights, setHighlightLocations])
|
||||
|
||||
const removeHighlightCallback = useCallback(
|
||||
async (id?: string) => {
|
||||
const highlightId = id || focusedHighlight?.id
|
||||
@ -89,7 +73,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
if (didDeleteHighlight) {
|
||||
removeHighlights(
|
||||
highlights.map(($0) => $0.id),
|
||||
highlightLocations
|
||||
props.highlightLocations
|
||||
)
|
||||
setHighlights(highlights.filter(($0) => $0.id !== highlightId))
|
||||
setFocusedHighlight(undefined)
|
||||
@ -97,16 +81,16 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
console.error('Failed to delete highlight')
|
||||
}
|
||||
},
|
||||
[focusedHighlight, highlights, highlightLocations]
|
||||
[focusedHighlight, highlights, props.highlightLocations]
|
||||
)
|
||||
|
||||
const updateHighlightsCallback = useCallback(
|
||||
(highlight: Highlight) => {
|
||||
removeHighlights([highlight.id], highlightLocations)
|
||||
removeHighlights([highlight.id], props.highlightLocations)
|
||||
const keptHighlights = highlights.filter(($0) => $0.id !== highlight.id)
|
||||
setHighlights([...keptHighlights, highlight])
|
||||
},
|
||||
[highlights, highlightLocations]
|
||||
[highlights, props.highlightLocations]
|
||||
)
|
||||
|
||||
const handleNativeShare = useCallback(
|
||||
@ -159,7 +143,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
selection: selection,
|
||||
articleId: props.articleId,
|
||||
existingHighlights: highlights,
|
||||
highlightStartEndOffsets: highlightLocations,
|
||||
highlightStartEndOffsets: props.highlightLocations,
|
||||
annotation: note,
|
||||
}, props.articleMutations)
|
||||
|
||||
@ -214,10 +198,22 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
selectionData,
|
||||
setSelectionData,
|
||||
canShareNative,
|
||||
highlightLocations,
|
||||
props.highlightLocations,
|
||||
]
|
||||
)
|
||||
|
||||
const scrollToHighlight = (id: string) => {
|
||||
const foundElement = document.querySelector(`[omnivore-highlight-id="${id}"]`)
|
||||
if(foundElement){
|
||||
foundElement.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth'
|
||||
})
|
||||
window.location.hash = `#${id}`
|
||||
props.setShowHighlightsModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect mouseclick on a highlight -- call `setFocusedHighlight` when highlight detected
|
||||
const handleClickHighlight = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@ -261,7 +257,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
})
|
||||
} else setFocusedHighlight(undefined)
|
||||
},
|
||||
[highlights, highlightLocations]
|
||||
[highlights, props.highlightLocations]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -469,9 +465,10 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
if (props.showHighlightsModal) {
|
||||
return (
|
||||
<HighlightsModal
|
||||
highlights={highlights}
|
||||
onOpenChange={() => props.setShowHighlightsModal(false)}
|
||||
deleteHighlightAction={(highlightId: string) => {
|
||||
highlights={highlights}
|
||||
onOpenChange={() => props.setShowHighlightsModal(false)}
|
||||
scrollToHighlight={scrollToHighlight}
|
||||
deleteHighlightAction={(highlightId: string) => {
|
||||
removeHighlightCallback(highlightId)
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -19,6 +19,7 @@ import { Pen, Trash } from 'phosphor-react'
|
||||
|
||||
type HighlightsModalProps = {
|
||||
highlights: Highlight[]
|
||||
scrollToHighlight?: (arg: string) => void;
|
||||
deleteHighlightAction?: (highlightId: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
@ -60,6 +61,7 @@ export function HighlightsModal(props: HighlightsModalProps): JSX.Element {
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
showDelete={!!props.deleteHighlightAction}
|
||||
scrollToHighlight={props.scrollToHighlight}
|
||||
deleteHighlightAction={() => {
|
||||
if (props.deleteHighlightAction) {
|
||||
props.deleteHighlightAction(highlight.id)
|
||||
@ -82,6 +84,7 @@ export function HighlightsModal(props: HighlightsModalProps): JSX.Element {
|
||||
type ModalHighlightViewProps = {
|
||||
highlight: Highlight
|
||||
showDelete: boolean
|
||||
scrollToHighlight?: (arg: string) => void;
|
||||
deleteHighlightAction: () => void
|
||||
}
|
||||
|
||||
@ -156,7 +159,7 @@ function ModalHighlightView(props: ModalHighlightViewProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<VStack>
|
||||
<HighlightView highlight={props.highlight} />
|
||||
<HighlightView scrollToHighlight={props.scrollToHighlight} highlight={props.highlight} />
|
||||
{props.highlight.annotation && !isEditing ? (
|
||||
<StyledText css={{ px: '24px' }}>{props.highlight.annotation}</StyledText>
|
||||
) : null}
|
||||
|
||||
@ -40,7 +40,7 @@ import { Label } from '../../../lib/networking/fragments/labelFragment'
|
||||
import { isVipUser } from '../../../lib/featureFlag'
|
||||
import { EmptyLibrary } from './EmptyLibrary'
|
||||
import TopBarProgress from 'react-topbar-progress-indicator'
|
||||
import { State } from '../../../lib/networking/fragments/articleFragment'
|
||||
import { State, PageType } from '../../../lib/networking/fragments/articleFragment'
|
||||
|
||||
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
|
||||
|
||||
@ -53,7 +53,7 @@ const timeZoneHourDiff = -new Date().getTimezoneOffset() / 60
|
||||
const SAVED_SEARCHES: Record<string, string> = {
|
||||
Inbox: `in:inbox`,
|
||||
'Read Later': `in:inbox -label:Newsletter`,
|
||||
Highlighted: `in:inbox has:highlights`,
|
||||
Highlights: `type:highlights`,
|
||||
Today: `in:inbox saved:${
|
||||
new Date(new Date().getTime() - 24 * 3600000).toISOString().split('T')[0]
|
||||
}Z${timeZoneHourDiff.toLocaleString('en-US', {
|
||||
@ -112,23 +112,23 @@ export function HomeFeedContainer(props: HomeFeedContainerProps): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setQueryInputs, router.isReady, router.query])
|
||||
|
||||
const { articlesPages, size, setSize, isValidating, performActionOnItem } =
|
||||
const { itemsPages, size, setSize, isValidating, performActionOnItem } =
|
||||
useGetLibraryItemsQuery(queryInputs)
|
||||
|
||||
const hasMore = useMemo(() => {
|
||||
if (!articlesPages) {
|
||||
if (!itemsPages) {
|
||||
return false
|
||||
}
|
||||
return articlesPages[articlesPages.length - 1].articles.pageInfo.hasNextPage
|
||||
}, [articlesPages])
|
||||
return itemsPages[itemsPages.length - 1].search.pageInfo.hasNextPage
|
||||
}, [itemsPages])
|
||||
|
||||
const libraryItems = useMemo(() => {
|
||||
const items =
|
||||
articlesPages?.flatMap((ad) => {
|
||||
return ad.articles.edges
|
||||
itemsPages?.flatMap((ad) => {
|
||||
return ad.search.edges
|
||||
}) || []
|
||||
return items
|
||||
}, [articlesPages, performActionOnItem])
|
||||
}, [itemsPages, performActionOnItem])
|
||||
|
||||
const handleFetchMore = useCallback(() => {
|
||||
if (isValidating || !hasMore) {
|
||||
@ -262,7 +262,8 @@ export function HomeFeedContainer(props: HomeFeedContainerProps): JSX.Element {
|
||||
if (item.node.state === State.PROCESSING) {
|
||||
router.push(`/${username}/links/${item.node.id}`)
|
||||
} else {
|
||||
router.push(`/${username}/${item.node.slug}`)
|
||||
const dl = item.node.pageType === PageType.HIGHLIGHTS ? `#${item.node.id}` : ''
|
||||
router.push(`/${username}/${item.node.slug}` + dl)
|
||||
}
|
||||
}
|
||||
break
|
||||
@ -440,8 +441,8 @@ export function HomeFeedContainer(props: HomeFeedContainerProps): JSX.Element {
|
||||
setSize(size + 1)
|
||||
}}
|
||||
hasMore={hasMore}
|
||||
hasData={!!articlesPages}
|
||||
totalItems={articlesPages?.[0].articles.pageInfo.totalCount || 0}
|
||||
hasData={!!itemsPages}
|
||||
totalItems={itemsPages?.[0].search.pageInfo.totalCount || 0}
|
||||
isValidating={isValidating}
|
||||
shareTarget={shareTarget}
|
||||
setShareTarget={setShareTarget}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { SearchIcon } from '../../elements/images/SearchIcon'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { DropdownOption, Dropdown } from '../../elements/DropdownElements'
|
||||
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
||||
import { FormInput } from '../../elements/FormElements'
|
||||
import { searchBarCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts'
|
||||
import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
|
||||
@ -16,7 +15,19 @@ type LibrarySearchBarProps = {
|
||||
applySearchQuery: (searchQuery: string) => void
|
||||
}
|
||||
|
||||
type LibraryFilter = 'in:inbox' | 'in:all' | 'in:archive' | 'type:file'
|
||||
type LibraryFilter =
|
||||
| 'in:inbox'
|
||||
| 'in:all'
|
||||
| 'in:archive'
|
||||
| 'type:file'
|
||||
| 'type:highlights'
|
||||
| `saved:${string}`
|
||||
| `sort:updated`
|
||||
|
||||
// get last week's date
|
||||
const recentlySavedStartDate = new Date(
|
||||
new Date().getTime() - 7 * 24 * 60 * 60 * 1000
|
||||
).toLocaleDateString('en-US')
|
||||
|
||||
const FOCUSED_BOXSHADOW = '0px 0px 2px 2px rgba(255, 234, 159, 0.56)'
|
||||
|
||||
@ -153,6 +164,21 @@ export function DropdownFilterMenu(
|
||||
title="Files"
|
||||
hideSeparator
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.onFilterChange('type:highlights')}
|
||||
title="Highlights"
|
||||
hideSeparator
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.onFilterChange(`saved:${recentlySavedStartDate}`)}
|
||||
title="Recently Saved"
|
||||
hideSeparator
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.onFilterChange(`sort:updated`)}
|
||||
title="Recently Read"
|
||||
hideSeparator
|
||||
/>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
@ -30,6 +30,16 @@ export enum State {
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
export enum PageType {
|
||||
ARTICLE = 'ARTICLE',
|
||||
BOOK = 'BOOK',
|
||||
FILE = 'FILE',
|
||||
PROFILE = 'PROFILE',
|
||||
WEBSITE = 'WEBSITE',
|
||||
HIGHLIGHTS = 'HIGHLIGHTS',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
export type ArticleFragmentData = {
|
||||
id: string
|
||||
title: string
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { gql } from 'graphql-request'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
import type { ArticleFragmentData } from '../fragments/articleFragment'
|
||||
import type { ArticleFragmentData, PageType, State } from '../fragments/articleFragment'
|
||||
import { ContentReader } from '../fragments/articleFragment'
|
||||
import { setLinkArchivedMutation } from '../mutations/setLinkArchivedMutation'
|
||||
import { deleteLinkMutation } from '../mutations/deleteLinkMutation'
|
||||
import { articleReadingProgressMutation } from '../mutations/articleReadingProgressMutation'
|
||||
@ -16,8 +17,8 @@ export type LibraryItemsQueryInput = {
|
||||
}
|
||||
|
||||
type LibraryItemsQueryResponse = {
|
||||
articlesPages?: LibraryItemsData[]
|
||||
articlesDataError?: unknown
|
||||
itemsPages?: LibraryItemsData[]
|
||||
itemsDataError?: unknown
|
||||
isLoading: boolean
|
||||
isValidating: boolean
|
||||
size: number
|
||||
@ -36,7 +37,7 @@ type LibraryItemAction =
|
||||
| 'refresh'
|
||||
|
||||
export type LibraryItemsData = {
|
||||
articles: LibraryItems
|
||||
search: LibraryItems
|
||||
}
|
||||
|
||||
export type LibraryItems = {
|
||||
@ -50,12 +51,30 @@ export type LibraryItem = {
|
||||
node: LibraryItemNode
|
||||
}
|
||||
|
||||
export type LibraryItemNode = ArticleFragmentData & {
|
||||
description?: string
|
||||
hasContent: boolean
|
||||
export type LibraryItemNode = {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
author?: string
|
||||
image?: string
|
||||
createdAt: string
|
||||
publishedAt?: string
|
||||
contentReader?: ContentReader
|
||||
originalArticleUrl: string
|
||||
sharedComment?: string
|
||||
readingProgressPercent: number
|
||||
readingProgressAnchorIndex: number
|
||||
slug: string
|
||||
isArchived: boolean
|
||||
description: string
|
||||
ownedByViewer: boolean
|
||||
uploadFileId: string
|
||||
labels?: Label[]
|
||||
pageId: string
|
||||
shortId: string
|
||||
quote: string
|
||||
annotation: string
|
||||
state: State
|
||||
pageType: PageType
|
||||
}
|
||||
|
||||
export type PageInfo = {
|
||||
@ -66,31 +85,6 @@ export type PageInfo = {
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
const libraryItemFragment = gql`
|
||||
fragment ArticleFields on Article {
|
||||
id
|
||||
title
|
||||
url
|
||||
author
|
||||
image
|
||||
savedAt
|
||||
createdAt
|
||||
publishedAt
|
||||
contentReader
|
||||
originalArticleUrl
|
||||
readingProgressPercent
|
||||
readingProgressAnchorIndex
|
||||
slug
|
||||
isArchived
|
||||
description
|
||||
linkId
|
||||
state
|
||||
labels {
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export function useGetLibraryItemsQuery({
|
||||
limit,
|
||||
sortDescending,
|
||||
@ -98,27 +92,38 @@ export function useGetLibraryItemsQuery({
|
||||
cursor,
|
||||
}: LibraryItemsQueryInput): LibraryItemsQueryResponse {
|
||||
const query = gql`
|
||||
query GetArticles(
|
||||
$sharedOnly: Boolean
|
||||
$sort: SortParams
|
||||
$after: String
|
||||
$first: Int
|
||||
$query: String
|
||||
) {
|
||||
articles(
|
||||
sharedOnly: $sharedOnly
|
||||
sort: $sort
|
||||
first: $first
|
||||
after: $after
|
||||
query: $query
|
||||
includePending: true
|
||||
) {
|
||||
... on ArticlesSuccess {
|
||||
query Search($after: String, $first: Int, $query: String) {
|
||||
search(first: $first, after: $after, query: $query) {
|
||||
... on SearchSuccess {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
...ArticleFields
|
||||
id
|
||||
title
|
||||
slug
|
||||
url
|
||||
pageType
|
||||
contentReader
|
||||
createdAt
|
||||
isArchived
|
||||
readingProgressPercent
|
||||
readingProgressAnchorIndex
|
||||
author
|
||||
image
|
||||
description
|
||||
publishedAt
|
||||
ownedByViewer
|
||||
originalArticleUrl
|
||||
uploadFileId
|
||||
labels {
|
||||
id
|
||||
name
|
||||
color
|
||||
}
|
||||
pageId
|
||||
shortId
|
||||
quote
|
||||
annotation
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
@ -129,21 +134,14 @@ export function useGetLibraryItemsQuery({
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
... on ArticlesError {
|
||||
... on SearchError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
${libraryItemFragment}
|
||||
${labelFragment}
|
||||
`
|
||||
|
||||
const variables = {
|
||||
sharedOnly: false,
|
||||
sort: {
|
||||
order: sortDescending ? 'DESCENDING' : 'ASCENDING',
|
||||
by: 'UPDATED_TIME',
|
||||
},
|
||||
after: cursor,
|
||||
first: limit,
|
||||
query: searchQuery,
|
||||
@ -162,12 +160,10 @@ export function useGetLibraryItemsQuery({
|
||||
limit,
|
||||
sortDescending,
|
||||
searchQuery,
|
||||
pageIndex === 0
|
||||
? undefined
|
||||
: previousResult.articles.pageInfo.endCursor,
|
||||
pageIndex === 0 ? undefined : previousResult.search.pageInfo.endCursor,
|
||||
]
|
||||
},
|
||||
(_query, _l, _s, _sq, cursor: string) => {
|
||||
(_query, _l, _s, _sq, cursor) => {
|
||||
return gqlFetcher(query, { ...variables, after: cursor }, true)
|
||||
},
|
||||
{ revalidateFirstPage: false }
|
||||
@ -182,7 +178,7 @@ export function useGetLibraryItemsQuery({
|
||||
// the response in the case of an error.
|
||||
if (!error && responsePages) {
|
||||
const errors = responsePages.filter(
|
||||
(d) => d.articles.errorCodes && d.articles.errorCodes.length > 0
|
||||
(d) => d.search.errorCodes && d.search.errorCodes.length > 0
|
||||
)
|
||||
if (errors?.length > 0) {
|
||||
responseError = errors
|
||||
@ -202,13 +198,13 @@ export function useGetLibraryItemsQuery({
|
||||
if (!responsePages) {
|
||||
return
|
||||
}
|
||||
for (const articlesData of responsePages) {
|
||||
const itemIndex = articlesData.articles.edges.indexOf(item)
|
||||
for (const searchResults of responsePages) {
|
||||
const itemIndex = searchResults.search.edges.indexOf(item)
|
||||
if (itemIndex !== -1) {
|
||||
if (typeof mutatedItem === 'undefined') {
|
||||
articlesData.articles.edges.splice(itemIndex, 1)
|
||||
searchResults.search.edges.splice(itemIndex, 1)
|
||||
} else {
|
||||
articlesData.articles.edges[itemIndex] = mutatedItem
|
||||
searchResults.search.edges[itemIndex] = mutatedItem
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -313,8 +309,8 @@ export function useGetLibraryItemsQuery({
|
||||
|
||||
return {
|
||||
isValidating,
|
||||
articlesPages: responsePages || undefined,
|
||||
articlesDataError: responseError,
|
||||
itemsPages: responsePages || undefined,
|
||||
itemsDataError: responseError,
|
||||
isLoading: !error && !data,
|
||||
performActionOnItem,
|
||||
size,
|
||||
|
||||
71
packages/web/stories/HighlightItemCard.stories.tsx
Normal file
71
packages/web/stories/HighlightItemCard.stories.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import { HighlightItemCard, HighlightItemCardProps } from '../components/patterns/LibraryCards/HighlightItemCard'
|
||||
import { updateThemeLocally } from '../lib/themeUpdater'
|
||||
import { ThemeId } from '../components/tokens/stitches.config'
|
||||
import { PageType, State } from '../lib/networking/fragments/articleFragment'
|
||||
|
||||
export default {
|
||||
title: 'Components/HighlightItemCard',
|
||||
component: HighlightItemCard,
|
||||
argTypes: {
|
||||
item: {
|
||||
description: 'The highlight.',
|
||||
},
|
||||
handleAction: {
|
||||
description: 'Action that fires on click.'
|
||||
}
|
||||
}
|
||||
} as ComponentMeta<typeof HighlightItemCard>
|
||||
|
||||
const highlight: HighlightItemCardProps = {
|
||||
handleAction: () => console.log('Handling Action'),
|
||||
item:{
|
||||
id: "nnnnn",
|
||||
shortId: "shortId",
|
||||
quote: "children not only participate in herding work, but are also encouraged to act independently in most other areas of life. They have a say in deciding when to eat, when to sleep, and what to wear, even at temperatures of -30C (-22F).",
|
||||
annotation: "Okay… this is wild! I love this independence. Wondering how I can reponsibly instill this type of indepence in my own kids…",
|
||||
createdAt: '',
|
||||
description: '',
|
||||
isArchived: false,
|
||||
originalArticleUrl: 'https://example.com',
|
||||
ownedByViewer: true,
|
||||
pageId: '1',
|
||||
readingProgressAnchorIndex: 12,
|
||||
readingProgressPercent: 50,
|
||||
slug: 'slug',
|
||||
title: "This is a title",
|
||||
uploadFileId: '1',
|
||||
url: 'https://example.com',
|
||||
author: 'Author',
|
||||
image: 'https://logos-world.net/wp-content/uploads/2021/11/Unity-New-Logo.png',
|
||||
state: State.SUCCEEDED,
|
||||
pageType: PageType.HIGHLIGHTS,
|
||||
},
|
||||
}
|
||||
|
||||
const Template = (props: HighlightItemCardProps) => <HighlightItemCard {...props} />
|
||||
|
||||
export const LightHighlightItemCard: ComponentStory<
|
||||
typeof HighlightItemCard
|
||||
> = (args: any) => {
|
||||
updateThemeLocally(ThemeId.Light)
|
||||
return (
|
||||
<Template {...args}/>
|
||||
)
|
||||
}
|
||||
export const DarkHighlightItemCard: ComponentStory<
|
||||
typeof HighlightItemCard
|
||||
> = (args: any) => {
|
||||
updateThemeLocally(ThemeId.Dark)
|
||||
return (
|
||||
<Template {...args}/>
|
||||
)
|
||||
}
|
||||
|
||||
LightHighlightItemCard.args = {
|
||||
...highlight
|
||||
}
|
||||
|
||||
DarkHighlightItemCard.args = {
|
||||
...highlight
|
||||
}
|
||||
Reference in New Issue
Block a user