diff --git a/packages/api/src/services/highlights.ts b/packages/api/src/services/highlights.ts
index 80a011589..327038aca 100644
--- a/packages/api/src/services/highlights.ts
+++ b/packages/api/src/services/highlights.ts
@@ -290,6 +290,11 @@ export const searchHighlights = async (
const queryBuilder = tx
.getRepository(Highlight)
.createQueryBuilder('highlight')
+ .innerJoin(
+ 'highlight.libraryItem',
+ 'libraryItem',
+ 'highlight.libraryItemId = libraryItem.id AND libraryItem.deletedAt IS NULL'
+ )
.andWhere('highlight.userId = :userId', { userId })
.orderBy('highlight.updatedAt', 'DESC')
.take(limit)
diff --git a/packages/web/components/templates/navMenu/LibraryMenu.tsx b/packages/web/components/templates/navMenu/LibraryMenu.tsx
index e39923bca..d852a2087 100644
--- a/packages/web/components/templates/navMenu/LibraryMenu.tsx
+++ b/packages/web/components/templates/navMenu/LibraryMenu.tsx
@@ -210,10 +210,10 @@ const LibraryNav = (props: LibraryFilterMenuProps): JSX.Element => {
filterTerm="in:library use:folders"
icon={}
/>
- }
/>
+ error?: unknown
+ isLoading: boolean
+ isValidating: boolean
+ size: number
+ setSize: (
+ size: number | ((_size: number) => number)
+ ) => Promise
+ mutate: () => void
+}
+
+interface HighlightsVariables {
+ first?: number
+ query?: string
+}
+
+interface HighlightEdge {
+ node: Highlight
+ cursor: string
+}
+
+interface HighlightsData {
+ highlights: {
+ edges: Array
+ pageInfo: PageInfo
+ errorCodes?: Array
+ }
+}
+
+export const useGetHighlights = (
+ variables: HighlightsVariables
+): HighlightsResponse => {
+ const query = gql`
+ query Highlights($first: Int, $after: String, $query: String) {
+ highlights(first: $first, after: $after, query: $query) {
+ ... on HighlightsSuccess {
+ edges {
+ node {
+ ...HighlightFields
+ libraryItem {
+ id
+ title
+ author
+ slug
+ }
+ }
+ cursor
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ ... on HighlightsError {
+ errorCodes
+ }
+ }
+ }
+ ${highlightFragment}
+ `
+
+ const getKey = (pageIndex: number, previousPageData: any) => {
+ if (previousPageData && !previousPageData.highlights.edges) return null
+
+ if (pageIndex === 0) return '0'
+
+ return previousPageData.highlights.pageInfo.endCursor
+ }
+
+ const fetcher = async (cursor: string | null) =>
+ gqlFetcher(query, { ...variables, after: cursor }, true)
+
+ const { data, error, isValidating, mutate, size, setSize } = useSWRInfinite(
+ getKey,
+ fetcher,
+ { revalidateFirstPage: false }
+ )
+
+ let responseError = error
+ let responsePages = data as Array | undefined
+
+ // We need to check the response errors here and return the error
+ // it will be nested in the data pages, if there is one error,
+ // we invalidate the data and return the error. We also zero out
+ // the response in the case of an error.
+ if (!error && responsePages) {
+ const errors = responsePages.filter(
+ (d) => d.highlights.errorCodes && d.highlights.errorCodes.length > 0
+ )
+ if (errors?.length > 0) {
+ responseError = errors
+ responsePages = undefined
+ }
+ }
+
+ return {
+ isValidating,
+ data: responsePages || undefined,
+ error: responseError,
+ isLoading: !error && !data,
+ size,
+ setSize,
+ mutate,
+ }
+}
diff --git a/packages/web/pages/highlights.tsx b/packages/web/pages/highlights.tsx
new file mode 100644
index 000000000..abdc5a183
--- /dev/null
+++ b/packages/web/pages/highlights.tsx
@@ -0,0 +1,352 @@
+import {
+ autoUpdate,
+ offset,
+ size,
+ useFloating,
+ useHover,
+ useInteractions,
+} from '@floating-ui/react'
+import { NextRouter, useRouter } from 'next/router'
+import { useCallback, useMemo, useState } from 'react'
+import { Toaster } from 'react-hot-toast'
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import { TrashIcon } from '../components/elements/icons/TrashIcon'
+import { LabelChip } from '../components/elements/LabelChip'
+import { Box, HStack, VStack } from '../components/elements/LayoutPrimitives'
+import { ConfirmationModal } from '../components/patterns/ConfirmationModal'
+import { HighlightHoverActions } from '../components/patterns/HighlightHoverActions'
+import { HighlightViewNote } from '../components/patterns/HighlightNotes'
+import { timeAgo } from '../components/patterns/LibraryCards/LibraryCardStyles'
+import { SetHighlightLabelsModalPresenter } from '../components/templates/article/SetLabelsModalPresenter'
+import { EmptyHighlights } from '../components/templates/homeFeed/EmptyHighlights'
+import { LibraryFilterMenu } from '../components/templates/navMenu/LibraryMenu'
+import { theme } from '../components/tokens/stitches.config'
+import { useApplyLocalTheme } from '../lib/hooks/useApplyLocalTheme'
+import { useFetchMore } from '../lib/hooks/useFetchMoreScroll'
+import { Highlight } from '../lib/networking/fragments/highlightFragment'
+import { deleteHighlightMutation } from '../lib/networking/mutations/deleteHighlightMutation'
+import { useGetHighlights } from '../lib/networking/queries/useGetHighlights'
+import {
+ useGetViewerQuery,
+ UserBasicData,
+} from '../lib/networking/queries/useGetViewerQuery'
+import { highlightColor } from '../lib/themeUpdater'
+import { showErrorToast, showSuccessToast } from '../lib/toastHelpers'
+
+const PAGE_SIZE = 10
+
+export default function HighlightsPage(): JSX.Element {
+ const router = useRouter()
+ const viewer = useGetViewerQuery()
+ const [showFilterMenu, setShowFilterMenu] = useState(false)
+ const [_, setShowAddLinkModal] = useState(false)
+
+ useApplyLocalTheme()
+
+ const { isLoading, setSize, size, data, mutate } = useGetHighlights({
+ first: PAGE_SIZE,
+ })
+
+ const hasMore = useMemo(() => {
+ if (!data) {
+ return false
+ }
+ return data[data.length - 1].highlights.pageInfo.hasNextPage
+ }, [data])
+
+ const handleFetchMore = useCallback(() => {
+ if (isLoading || !hasMore) {
+ return
+ }
+ setSize(size + 1)
+ }, [isLoading, hasMore, setSize, size])
+
+ useFetchMore(handleFetchMore)
+
+ const highlights = useMemo(() => {
+ if (!data) {
+ return []
+ }
+ return data.flatMap((res) => res.highlights.edges.map((edge) => edge.node))
+ }, [data])
+
+ if (!highlights.length) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {
+ router?.push(`/home?q=${searchQuery}`)
+ }}
+ />
+
+ {highlights.map((highlight) => {
+ return (
+ viewer.viewerData?.me && (
+
+ )
+ )
+ })}
+
+
+ )
+}
+
+type HighlightCardProps = {
+ highlight: Highlight
+ viewer: UserBasicData
+ router: NextRouter
+ mutate: () => void
+}
+
+type HighlightAnnotationProps = {
+ highlight: Highlight
+}
+
+function HighlightAnnotation({
+ highlight,
+}: HighlightAnnotationProps): JSX.Element {
+ const [noteMode, setNoteMode] = useState<'edit' | 'preview'>('preview')
+ const [annotation, setAnnotation] = useState(highlight.annotation)
+
+ return (
+ {
+ setAnnotation(highlight.annotation)
+ }}
+ />
+ )
+}
+
+function HighlightCard(props: HighlightCardProps): JSX.Element {
+ const [isOpen, setIsOpen] = useState(false)
+ const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] =
+ useState(undefined)
+ const [labelsTarget, setLabelsTarget] = useState(
+ undefined
+ )
+
+ const viewInReader = useCallback(
+ (highlightId: string) => {
+ const router = props.router
+ const viewer = props.viewer
+ const item = props.highlight.libraryItem
+
+ if (!router || !router.isReady || !viewer || !item) {
+ showErrorToast('Error navigating to highlight')
+ return
+ }
+
+ router.push(
+ {
+ pathname: '/[username]/[slug]',
+ query: {
+ username: viewer.profile.username,
+ slug: item.slug,
+ },
+ hash: highlightId,
+ },
+ `${viewer.profile.username}/${item.slug}#${highlightId}`,
+ {
+ scroll: false,
+ }
+ )
+ },
+ [props.highlight.libraryItem, props.viewer, props.router]
+ )
+
+ const { refs, floatingStyles, context } = useFloating({
+ open: isOpen,
+ onOpenChange: setIsOpen,
+ middleware: [
+ offset({
+ mainAxis: -25,
+ }),
+ size(),
+ ],
+ placement: 'top-end',
+ whileElementsMounted: autoUpdate,
+ })
+
+ const hover = useHover(context)
+
+ const { getReferenceProps, getFloatingProps } = useInteractions([hover])
+
+ return (
+
+
+
+
+
+
+ {timeAgo(props.highlight.updatedAt)}
+
+ {props.highlight.quote && (
+
+ {props.highlight.quote}
+
+ )}
+
+ {props.highlight.labels && (
+
+ {props.highlight.labels.map((label) => {
+ return (
+
+ )
+ })}
+
+ )}
+
+ {props.highlight.libraryItem?.title}
+
+
+ {props.highlight.libraryItem?.author}
+
+ {showConfirmDeleteHighlightId && (
+ {
+ ;(async () => {
+ const highlightId = showConfirmDeleteHighlightId
+ const success = await deleteHighlightMutation(
+ props.highlight.libraryItem?.id || '',
+ showConfirmDeleteHighlightId
+ )
+ props.mutate()
+ if (success) {
+ showSuccessToast('Highlight deleted.', {
+ position: 'bottom-right',
+ })
+ const event = new CustomEvent('deleteHighlightbyId', {
+ detail: highlightId,
+ })
+ document.dispatchEvent(event)
+ } else {
+ showErrorToast('Error deleting highlight', {
+ position: 'bottom-right',
+ })
+ }
+ })()
+ setShowConfirmDeleteHighlightId(undefined)
+ }}
+ onOpenChange={() => setShowConfirmDeleteHighlightId(undefined)}
+ icon={
+
+ }
+ />
+ )}
+ {labelsTarget && (
+ {
+ // Don't actually need to do something here
+ console.log('update highlight: ', highlight)
+ }}
+ onOpenChange={() => {
+ props.mutate()
+ setLabelsTarget(undefined)
+ }}
+ />
+ )}
+
+ )
+}