From 28255e073f46f8080627a93b632ba0153b697d05 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 7 Jun 2024 16:15:09 +0800 Subject: [PATCH 1/5] add get highlights api integration --- .../networking/fragments/highlightFragment.ts | 2 + .../networking/queries/useGetHighlights.tsx | 110 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 packages/web/lib/networking/queries/useGetHighlights.tsx diff --git a/packages/web/lib/networking/fragments/highlightFragment.ts b/packages/web/lib/networking/fragments/highlightFragment.ts index 0185bd524..6f6064b46 100644 --- a/packages/web/lib/networking/fragments/highlightFragment.ts +++ b/packages/web/lib/networking/fragments/highlightFragment.ts @@ -1,4 +1,5 @@ import { gql } from 'graphql-request' +import { LibraryItemNode } from '../queries/useGetLibraryItemsQuery' import { Label } from './labelFragment' export const highlightFragment = gql` @@ -45,6 +46,7 @@ export type Highlight = { color?: string highlightPositionPercent?: number highlightPositionAnchorIndex?: number + libraryItem?: LibraryItemNode } export type User = { diff --git a/packages/web/lib/networking/queries/useGetHighlights.tsx b/packages/web/lib/networking/queries/useGetHighlights.tsx new file mode 100644 index 000000000..635426d5e --- /dev/null +++ b/packages/web/lib/networking/queries/useGetHighlights.tsx @@ -0,0 +1,110 @@ +import { gql } from 'graphql-request' +import useSWRInfinite from 'swr/infinite' +import { Highlight, highlightFragment } from '../fragments/highlightFragment' +import { gqlFetcher } from '../networkHelpers' +import { PageInfo } from './useGetLibraryItemsQuery' + +interface HighlightsResponse { + highlightsData?: Array + highlightsError?: unknown + isLoading: boolean + isValidating: boolean + error: boolean + size: number + setSize: ( + size: number | ((_size: number) => number) + ) => Promise + mutate: () => void +} + +interface HighlightsVariables { + first?: number + after?: string + 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 + } + 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 `${query}_${variables.first}_${variables.query}` + + return `${query}_${previousPageData.highlights.pageInfo.endCursor}_${variables.first}_${variables.query}` + } + + const fetcher = async () => gqlFetcher(query, variables, 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, + highlightsData: responsePages || undefined, + highlightsError: responseError, + isLoading: !error && !data, + error: !!error, + size, + setSize, + mutate, + } +} From 470413b70405cd00ace4146f719487dcb5532ead Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sat, 8 Jun 2024 10:50:44 +0800 Subject: [PATCH 2/5] add basic highlights web UI --- packages/api/src/services/highlights.ts | 5 + .../templates/navMenu/LibraryMenu.tsx | 4 +- .../networking/queries/useGetHighlights.tsx | 23 +-- packages/web/pages/highlights.tsx | 166 ++++++++++++++++++ 4 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 packages/web/pages/highlights.tsx 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={} /> - } /> - highlightsError?: unknown + data?: Array + error?: unknown isLoading: boolean isValidating: boolean - error: boolean size: number setSize: ( size: number | ((_size: number) => number) @@ -19,7 +18,6 @@ interface HighlightsResponse { interface HighlightsVariables { first?: number - after?: string query?: string } @@ -46,6 +44,11 @@ export const useGetHighlights = ( edges { node { ...HighlightFields + libraryItem { + id + title + author + } } cursor } @@ -67,12 +70,13 @@ export const useGetHighlights = ( const getKey = (pageIndex: number, previousPageData: any) => { if (previousPageData && !previousPageData.highlights.edges) return null - if (pageIndex === 0) return `${query}_${variables.first}_${variables.query}` + if (pageIndex === 0) return '0' - return `${query}_${previousPageData.highlights.pageInfo.endCursor}_${variables.first}_${variables.query}` + return previousPageData.highlights.pageInfo.endCursor } - const fetcher = async () => gqlFetcher(query, variables, true) + const fetcher = async (cursor: string | null) => + gqlFetcher(query, { ...variables, after: cursor }, true) const { data, error, isValidating, mutate, size, setSize } = useSWRInfinite( getKey, @@ -99,10 +103,9 @@ export const useGetHighlights = ( return { isValidating, - highlightsData: responsePages || undefined, - highlightsError: responseError, + data: responsePages || undefined, + error: responseError, isLoading: !error && !data, - error: !!error, size, setSize, mutate, diff --git a/packages/web/pages/highlights.tsx b/packages/web/pages/highlights.tsx new file mode 100644 index 000000000..3a4cbd551 --- /dev/null +++ b/packages/web/pages/highlights.tsx @@ -0,0 +1,166 @@ +import { useRouter } from 'next/router' +import { useCallback, useMemo, useState } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { LabelChip } from '../components/elements/LabelChip' +import { Box, HStack, VStack } from '../components/elements/LayoutPrimitives' +import { EmptyHighlights } from '../components/templates/homeFeed/EmptyHighlights' +import { LibraryFilterMenu } from '../components/templates/navMenu/LibraryMenu' +import { useApplyLocalTheme } from '../lib/hooks/useApplyLocalTheme' +import { useFetchMore } from '../lib/hooks/useFetchMoreScroll' +import { Highlight } from '../lib/networking/fragments/highlightFragment' +import { useGetHighlights } from '../lib/networking/queries/useGetHighlights' +import { highlightColor } from '../lib/themeUpdater' + +const PAGE_SIZE = 10 + +export default function HighlightsPage(): JSX.Element { + const router = useRouter() + const [showFilterMenu, setShowFilterMenu] = useState(false) + const [_, setShowAddLinkModal] = useState(false) + + useApplyLocalTheme() + + const { isLoading, isValidating, mutate, setSize, size, data, error } = + 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 + })} + + + ) +} + +interface HighlightCardProps { + highlight: Highlight +} + +function HighlightCard(props: HighlightCardProps): JSX.Element { + return ( + + + + {props.highlight.quote && ( + + {props.highlight.quote} + + )} + {props.highlight.labels && ( + + {props.highlight.labels.map((label) => { + return ( + + ) + })} + + )} + + {props.highlight.libraryItem?.title} + + + {props.highlight.libraryItem?.author} + + + + ) +} From d87c8d83ca9b486bf5d68decd2071ffdd5b2ad89 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sun, 9 Jun 2024 08:56:30 +0800 Subject: [PATCH 3/5] add annotation --- packages/web/pages/highlights.tsx | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/web/pages/highlights.tsx b/packages/web/pages/highlights.tsx index 3a4cbd551..cf62a1f0d 100644 --- a/packages/web/pages/highlights.tsx +++ b/packages/web/pages/highlights.tsx @@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { LabelChip } from '../components/elements/LabelChip' import { Box, HStack, VStack } from '../components/elements/LayoutPrimitives' +import { HighlightViewNote } from '../components/patterns/HighlightNotes' import { EmptyHighlights } from '../components/templates/homeFeed/EmptyHighlights' import { LibraryFilterMenu } from '../components/templates/navMenu/LibraryMenu' import { useApplyLocalTheme } from '../lib/hooks/useApplyLocalTheme' @@ -88,10 +89,31 @@ export default function HighlightsPage(): JSX.Element { ) } -interface HighlightCardProps { +type HighlightCardProps = { highlight: Highlight } +type HighlightAnnotationProps = HighlightCardProps + +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 { return ( )} + {props.highlight.labels && ( Date: Sun, 9 Jun 2024 10:23:57 +0800 Subject: [PATCH 4/5] add hover menu --- .../networking/queries/useGetHighlights.tsx | 1 + packages/web/pages/highlights.tsx | 274 ++++++++++++++---- 2 files changed, 214 insertions(+), 61 deletions(-) diff --git a/packages/web/lib/networking/queries/useGetHighlights.tsx b/packages/web/lib/networking/queries/useGetHighlights.tsx index 505bf4e28..09edb9a33 100644 --- a/packages/web/lib/networking/queries/useGetHighlights.tsx +++ b/packages/web/lib/networking/queries/useGetHighlights.tsx @@ -48,6 +48,7 @@ export const useGetHighlights = ( id title author + slug } } cursor diff --git a/packages/web/pages/highlights.tsx b/packages/web/pages/highlights.tsx index cf62a1f0d..5c760e3c9 100644 --- a/packages/web/pages/highlights.tsx +++ b/packages/web/pages/highlights.tsx @@ -1,31 +1,51 @@ -import { useRouter } from 'next/router' +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 { 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, isValidating, mutate, setSize, size, data, error } = - useGetHighlights({ - first: PAGE_SIZE, - }) + const { isLoading, setSize, size, data, mutate } = useGetHighlights({ + first: PAGE_SIZE, + }) const hasMore = useMemo(() => { if (!data) { @@ -65,6 +85,8 @@ export default function HighlightsPage(): JSX.Element { return ( + + {highlights.map((highlight) => { - return + return ( + viewer.viewerData?.me && ( + + ) + ) })} @@ -91,11 +123,18 @@ export default function HighlightsPage(): JSX.Element { type HighlightCardProps = { highlight: Highlight + viewer: UserBasicData + router: NextRouter + mutate: () => void } -type HighlightAnnotationProps = HighlightCardProps +type HighlightAnnotationProps = { + highlight: Highlight +} -function HighlightAnnotation({ highlight }: HighlightAnnotationProps): JSX.Element { +function HighlightAnnotation({ + highlight, +}: HighlightAnnotationProps): JSX.Element { const [noteMode, setNoteMode] = useState<'edit' | 'preview'>('preview') const [annotation, setAnnotation] = useState(highlight.annotation) @@ -115,8 +154,63 @@ function HighlightAnnotation({ highlight }: HighlightAnnotationProps): JSX.Eleme } 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 ( - - - - {props.highlight.quote && ( - - {props.highlight.quote} - - )} - - {props.highlight.labels && ( - - {props.highlight.labels.map((label) => { - return ( - - ) - })} - - )} - + + {props.highlight.quote && ( + + {props.highlight.quote} + + )} + + {props.highlight.labels && ( + - {props.highlight.libraryItem?.title} - - { + 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) }} - > - {props.highlight.libraryItem?.author} - - - + onOpenChange={() => setShowConfirmDeleteHighlightId(undefined)} + icon={ + + } + /> + )} + {labelsTarget && ( + { + // Don't actually need to do something here + console.log('update highlight: ', highlight) + }} + onOpenChange={() => { + props.mutate() + setLabelsTarget(undefined) + }} + /> + )} + ) } From 0df6fabfa88dd2413e3d5bc43cd7ce03afd05ddf Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sun, 9 Jun 2024 10:32:54 +0800 Subject: [PATCH 5/5] add date --- packages/web/pages/highlights.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/web/pages/highlights.tsx b/packages/web/pages/highlights.tsx index 5c760e3c9..abdc5a183 100644 --- a/packages/web/pages/highlights.tsx +++ b/packages/web/pages/highlights.tsx @@ -17,6 +17,7 @@ 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' @@ -246,6 +247,16 @@ function HighlightCard(props: HighlightCardProps): JSX.Element { borderRadius: '2px', }} /> + + {timeAgo(props.highlight.updatedAt)} + {props.highlight.quote && ( {props.highlight.quote}