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) + }} + /> + )} + + ) +}