More moves to react-query for key library functions

This commit is contained in:
Jackson Harper
2024-07-29 17:30:47 +08:00
parent 457d1d9de9
commit 137283db0a
33 changed files with 697 additions and 755 deletions

View File

@ -8,7 +8,6 @@ import React from 'react'
export function ConfusedSlothIcon(): JSX.Element {
const { currentThemeIsDark } = useCurrentTheme()
console.log('is dark mdoe: ', currentThemeIsDark)
return currentThemeIsDark ? (
<ConfusedSlothIconDark />
) : (

View File

@ -80,6 +80,7 @@ export function HighlightViewNote(props: HighlightViewNoteProps): JSX.Element {
const saveText = useCallback(
(text: string) => {
;(async () => {
console.log('saving text: ', text)
const success = await updateHighlightMutation({
annotation: text,
libraryItemId: props.targetId,

View File

@ -50,6 +50,7 @@ export function HighlightViewNote(props: HighlightViewNoteProps): JSX.Element {
const saveText = useCallback(
(text: string, updateTime: Date, interactive: boolean) => {
;(async () => {
console.log('updating highlight text')
const success = await updateHighlightMutation({
annotation: text,
libraryItemId: props.targetId,

View File

@ -4,7 +4,7 @@ import {
DropdownOption,
DropdownSeparator,
} from '../elements/DropdownElements'
import { ArticleAttributes } from '../../lib/networking/queries/useGetArticleQuery'
import { ArticleAttributes } from '../../lib/networking/library_items/useLibraryItems'
import { State } from '../../lib/networking/fragments/articleFragment'
type DropdownMenuProps = {

View File

@ -1,5 +1,5 @@
import { Separator } from '@radix-ui/react-separator'
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { ArticleAttributes } from '../../../lib/networking/library_items/useLibraryItems'
import { Button } from '../../elements/Button'
import { Box, SpanBox } from '../../elements/LayoutPrimitives'
import { styled, theme } from '../../tokens/stitches.config'

View File

@ -1,7 +1,3 @@
import {
ArticleAttributes,
TextDirection,
} from '../../../lib/networking/queries/useGetArticleQuery'
import { Article } from './../../../components/templates/article/Article'
import { Box, HStack, SpanBox, VStack } from './../../elements/LayoutPrimitives'
import { StyledText } from './../../elements/StyledText'
@ -19,11 +15,13 @@ import { updateTheme, updateThemeLocally } from '../../../lib/themeUpdater'
import { ArticleMutations } from '../../../lib/articleActions'
import { LabelChip } from '../../elements/LabelChip'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { Recommendation } from '../../../lib/networking/library_items/useLibraryItems'
import {
ArticleAttributes,
Recommendation,
TextDirection,
} from '../../../lib/networking/library_items/useLibraryItems'
import { Avatar } from '../../elements/Avatar'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { AISummary } from './AISummary'
import { userHasFeature } from '../../../lib/featureFlag'
type ArticleContainerProps = {
viewer: UserBasicData

View File

@ -1,4 +1,4 @@
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { ArticleAttributes } from '../../../lib/networking/library_items/useLibraryItems'
import { Box, VStack } from '../../elements/LayoutPrimitives'
import { v4 as uuidv4 } from 'uuid'
import { nanoid } from 'nanoid'
@ -42,13 +42,15 @@ type EpubPatch = {
export default function EpubContainer(props: EpubContainerProps): JSX.Element {
const epubRef = useRef<HTMLDivElement | null>(null)
const renditionRef = useRef<Rendition | undefined>(undefined)
const [shareTarget, setShareTarget] =
useState<Highlight | undefined>(undefined)
const [shareTarget, setShareTarget] = useState<Highlight | undefined>(
undefined
)
const [touchStart, setTouchStart] = useState(0)
const [notebookKey, setNotebookKey] = useState<string>(uuidv4())
const [noteTarget, setNoteTarget] = useState<Highlight | undefined>(undefined)
const [noteTargetPageIndex, setNoteTargetPageIndex] =
useState<number | undefined>(undefined)
const [noteTargetPageIndex, setNoteTargetPageIndex] = useState<
number | undefined
>(undefined)
const highlightsRef = useRef<Highlight[]>([])
const book = useMemo(() => {

View File

@ -9,8 +9,8 @@ import { VStack } from '../../elements/LayoutPrimitives'
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { useCallback, useState } from 'react'
import { StyledTextArea } from '../../elements/StyledTextArea'
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
import { showErrorToast } from '../../../lib/toastHelpers'
import { useUpdateHighlight } from '../../../lib/networking/highlights/useItemHighlights'
type HighlightNoteModalProps = {
author: string
@ -25,6 +25,7 @@ type HighlightNoteModalProps = {
export function HighlightNoteModal(
props: HighlightNoteModalProps
): JSX.Element {
const updateHighlight = useUpdateHighlight()
const [noteContent, setNoteContent] = useState(
props.highlight?.annotation ?? ''
)
@ -38,20 +39,24 @@ export function HighlightNoteModal(
const saveNoteChanges = useCallback(async () => {
if (noteContent != props.highlight?.annotation && props.highlight?.id) {
const result = await updateHighlightMutation({
libraryItemId: props.libraryItemId,
highlightId: props.highlight?.id,
annotation: noteContent,
color: props.highlight?.color,
})
if (result) {
console.log('updating highlight textsdsdfsd')
try {
const result = await updateHighlight.mutateAsync({
itemId: props.libraryItemId,
input: {
libraryItemId: props.libraryItemId,
highlightId: props.highlight?.id,
annotation: noteContent,
color: props.highlight?.color,
},
})
props.onUpdate({ ...props.highlight, annotation: noteContent })
props.onOpenChange(false)
} else {
return result?.id
} catch (err) {
showErrorToast('Error updating your note', { position: 'bottom-right' })
return undefined
}
document.dispatchEvent(new Event('highlightsUpdated'))
}
if (!props.highlight && props.createHighlightForNote) {
const result = await props.createHighlightForNote(noteContent)

View File

@ -16,10 +16,10 @@ import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery
import { ReadableItem } from '../../../lib/networking/library_items/useLibraryItems'
import { SetHighlightLabelsModalPresenter } from './SetLabelsModalPresenter'
import { ArticleNotes } from '../../patterns/ArticleNotes'
import { useGetArticleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
import { formattedShortTime } from '../../../lib/dateFormatting'
import { isDarkTheme } from '../../../lib/themeUpdater'
import { sortHighlights } from '../../../lib/highlights/sortHighlights'
import { useGetLibraryItemContent } from '../../../lib/networking/library_items/useLibraryItems'
type NotebookContentProps = {
viewer: UserBasicData
@ -43,11 +43,10 @@ type NoteState = {
export function NotebookContent(props: NotebookContentProps): JSX.Element {
const isDark = isDarkTheme()
const { articleData, mutate } = useGetArticleQuery({
slug: props.item.slug,
username: props.viewer.profile.username,
includeFriendsHighlights: false,
})
const { data: article } = useGetLibraryItemContent(
props.viewer.profile.username as string,
props.item.slug as string
)
const [noteText, setNoteText] = useState<string>('')
const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] =
useState<undefined | string>(undefined)
@ -112,7 +111,7 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
)
const highlights = useMemo(() => {
const result = articleData?.article.article.highlights
const result = article?.highlights
const note = result?.find((h) => h.type === 'NOTE')
if (note) {
noteState.current.note = note
@ -122,7 +121,7 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
setNoteText('')
}
return result
}, [articleData])
}, [article])
useEffect(() => {
if (highlights && props.onAnnotationsChanged) {
@ -179,16 +178,6 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
const [lastChanged, setLastChanged] = useState<Date | undefined>(undefined)
const [lastSaved, setLastSaved] = useState<Date | undefined>(undefined)
useEffect(() => {
const highlightsUpdated = () => {
mutate()
}
document.addEventListener('highlightsUpdated', highlightsUpdated)
return () => {
document.removeEventListener('highlightsUpdated', highlightsUpdated)
}
}, [mutate])
return (
<VStack
tabIndex={-1}
@ -256,9 +245,6 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
viewInReader={props.viewInReader}
setSetLabelsTarget={setLabelsTarget}
setShowConfirmDeleteHighlightId={setShowConfirmDeleteHighlightId}
updateHighlight={() => {
mutate()
}}
/>
))}
{sortedHighlights.length === 0 && (
@ -298,7 +284,6 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
props.item.id,
showConfirmDeleteHighlightId
)
mutate()
if (success) {
showSuccessToast('Highlight deleted.', {
position: 'bottom-right',
@ -333,7 +318,6 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
console.log('update highlight: ', highlight)
}}
onOpenChange={() => {
mutate()
setLabelsTarget(undefined)
}}
/>

View File

@ -1,4 +1,4 @@
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { ArticleAttributes } from '../../../lib/networking/library_items/useLibraryItems'
import { Box } from '../../elements/LayoutPrimitives'
import { v4 as uuidv4 } from 'uuid'
import { nanoid } from 'nanoid'
@ -36,8 +36,9 @@ export default function PdfArticleContainer(
const containerRef = useRef<HTMLDivElement | null>(null)
const [notebookKey, setNotebookKey] = useState<string>(uuidv4())
const [noteTarget, setNoteTarget] = useState<Highlight | undefined>(undefined)
const [noteTargetPageIndex, setNoteTargetPageIndex] =
useState<number | undefined>(undefined)
const [noteTargetPageIndex, setNoteTargetPageIndex] = useState<
number | undefined
>(undefined)
const highlightsRef = useRef<Highlight[]>([])
const annotationOmnivoreId = (annotation: Annotation): string | undefined => {

View File

@ -1,4 +1,4 @@
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { ArticleAttributes } from '../../../lib/networking/library_items/useLibraryItems'
import { Button } from '../../elements/Button'
import { HStack } from '../../elements/LayoutPrimitives'
import { theme } from '../../tokens/stitches.config'

View File

@ -1,7 +1,7 @@
import dayjs, { Dayjs } from 'dayjs'
import { useCallback, useState } from 'react'
import { updatePageMutation } from '../../../lib/networking/mutations/updatePageMutation'
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { ArticleAttributes } from '../../../lib/networking/library_items/useLibraryItems'
import { LibraryItem } from '../../../lib/networking/library_items/useLibraryItems'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { CloseButton } from '../../elements/CloseButton'

View File

@ -18,7 +18,10 @@ import {
LibraryItemNode,
LibraryItems,
LibraryItemsQueryInput,
useArchiveItem,
useDeleteItem,
useGetLibraryItems,
useUpdateItemReadStatus,
} from '../../../lib/networking/library_items/useLibraryItems'
import {
useGetViewerQuery,
@ -110,6 +113,10 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
const [linkToEdit, setLinkToEdit] = useState<LibraryItem>()
const [linkToUnsubscribe, setLinkToUnsubscribe] = useState<LibraryItem>()
const archiveItem = useArchiveItem()
const deleteItem = useDeleteItem()
const updateItemReadStatus = useUpdateItemReadStatus()
const [queryInputs, setQueryInputs] =
useState<LibraryItemsQueryInput>(defaultQuery)
@ -121,19 +128,6 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
error: fetchItemsError,
} = useGetLibraryItems(props.folder, queryInputs)
// useEffect(() => {
// const handleRevalidate = () => {
// ;(async () => {
// console.log('revalidating library')
// await mutate()
// })()
// }
// document.addEventListener('revalidateLibrary', handleRevalidate)
// return () => {
// document.removeEventListener('revalidateLibrary', handleRevalidate)
// }
// }, [mutate])
useEffect(() => {
if (queryValue.startsWith('#')) {
debouncedFetchSearchResults(
@ -167,13 +161,6 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
window.localStorage.setItem('nav-return', router.asPath)
}, [router.asPath])
// const hasMore = useMemo(() => {
// if (!itemsPages) {
// return false
// }
// return itemsPages[itemsPages.length - 1].search.pageInfo.hasNextPage
// }, [itemsPages])
const libraryItems = useMemo(() => {
const items =
itemsPages?.pages
@ -387,64 +374,98 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
return
}
// switch (action) {
// case 'showDetail':
// const username = viewerData?.me?.profile.username
// if (username) {
// setActiveCardId(item.node.id)
// if (item.node.state === State.PROCESSING) {
// router.push(`/article?url=${encodeURIComponent(item.node.url)}`)
// } else {
// const dl =
// item.node.pageType === PageType.HIGHLIGHTS
// ? `#${item.node.id}`
// : ''
// router.push(`/${username}/${item.node.slug}` + dl)
// }
// }
// break
// case 'showOriginal':
// const url = item.node.originalArticleUrl
// if (url) {
// window.open(url, '_blank')
// }
// break
// case 'archive':
// performActionOnItem('archive', item)
// break
// case 'unarchive':
// performActionOnItem('unarchive', item)
// break
// case 'delete':
// performActionOnItem('delete', item)
// break
// case 'restore':
// performActionOnItem('restore', item)
// break
// case 'mark-read':
// performActionOnItem('mark-read', item)
// break
// case 'mark-unread':
// performActionOnItem('mark-unread', item)
// break
// case 'set-labels':
// setLabelsTarget(item)
// break
// case 'open-notebook':
// if (!notebookTarget) {
// setNotebookTarget(item)
// } else {
// setNotebookTarget(undefined)
// }
// break
// case 'unsubscribe':
// performActionOnItem('unsubscribe', item)
// case 'update-item':
// performActionOnItem('update-item', item)
// break
// default:
// console.warn('unknown action: ', action)
// }
switch (action) {
case 'showDetail':
const username = viewerData?.me?.profile.username
if (username) {
setActiveCardId(item.node.id)
if (item.node.state === State.PROCESSING) {
router.push(`/article?url=${encodeURIComponent(item.node.url)}`)
} else {
router.push(`/${username}/${item.node.slug}`)
}
}
break
case 'showOriginal':
const url = item.node.originalArticleUrl
if (url) {
window.open(url, '_blank')
}
break
case 'archive':
case 'unarchive':
try {
await archiveItem.mutateAsync({
linkId: item.node.id,
archived: action == 'archive',
})
} catch {
showErrorToast(`Error ${action}ing item`, {
position: 'bottom-right',
})
return
}
showSuccessToast(`Item ${action}d`, {
position: 'bottom-right',
})
break
case 'delete':
try {
await deleteItem.mutateAsync(item.node.id)
} catch {
showErrorToast(`Error deleting item`, {
position: 'bottom-right',
})
return
}
showSuccessToast(`Item deleted`, {
position: 'bottom-right',
})
break
case 'mark-read':
case 'mark-unread':
const desc = action == 'mark-read' ? 'read' : 'unread'
const values =
action == 'mark-read'
? {
readingProgressPercent: 100,
readingProgressTopPercent: 100,
readingProgressAnchorIndex: 0,
}
: {
readingProgressPercent: 0,
readingProgressTopPercent: 0,
readingProgressAnchorIndex: 0,
}
try {
await updateItemReadStatus.mutateAsync({
id: item.node.id,
force: true,
...values,
})
} catch {
showErrorToast(`Error marking as ${desc}`, {
position: 'bottom-right',
})
return
}
break
case 'set-labels':
setLabelsTarget(item)
break
case 'open-notebook':
if (!notebookTarget) {
setNotebookTarget(item)
} else {
setNotebookTarget(undefined)
}
break
case 'unsubscribe':
setLinkToUnsubscribe(item.node)
break
default:
console.warn('unknown action: ', action)
}
}
const modalTargetItem = useMemo(() => {
@ -1187,9 +1208,6 @@ export function LibraryItemsLayout(
</VStack>
{props.showEditTitleModal && (
<EditLibraryItemModal
updateItem={(item: LibraryItem) =>
props.actionHandler('update-item', item)
}
onOpenChange={() => {
props.setShowEditTitleModal(false)
props.setLinkToEdit(undefined)

View File

@ -1,8 +1,8 @@
import { Highlight } from './networking/fragments/highlightFragment'
import { ArticleReadingProgressMutationInput } from './networking/mutations/articleReadingProgressMutation'
import { CreateHighlightInput } from './networking/mutations/createHighlightMutation'
import { MergeHighlightInput } from './networking/mutations/mergeHighlightMutation'
import { UpdateHighlightInput } from './networking/mutations/updateHighlightMutation'
import { CreateHighlightInput } from './networking/highlights/useItemHighlights'
export type ArticleMutations = {
createHighlightMutation: (

View File

@ -54,7 +54,7 @@ export async function createHighlight(
if (!input.selection.selection) {
return {}
}
console.log(' overlapping: ', input.selection.overlapHighlights)
const shouldMerge = input.selection.overlapHighlights.length > 0
const { range, selection } = input.selection

View File

@ -8,19 +8,20 @@ import type { SelectionAttributes } from './highlightHelpers'
/**
* Get the range of text with {@link SelectionAttributes} that user has selected
*
*
* Event Handlers for detecting/using new highlight selection are registered
*
*
* If the new highlight selection overlaps with existing highlights, the new selection is merged.
*
*
* @param highlightLocations existing highlights
* @returns selection range and its setter
*/
export function useSelection(
highlightLocations: HighlightLocation[]
): [SelectionAttributes | null, (x: SelectionAttributes | null) => void] {
const [touchStartPos, setTouchStartPos] =
useState<{ x: number; y: number } | undefined>(undefined)
const [touchStartPos, setTouchStartPos] = useState<
{ x: number; y: number } | undefined
>(undefined)
const [selectionAttributes, setSelectionAttributes] =
useState<SelectionAttributes | null>(null)
@ -246,32 +247,38 @@ async function makeSelectionRange(): Promise<
/**
* Edge case:
* If the selection ends on range endContainer (or startContainer in reverse select) but no text is selected (i.e. selection ends at
* an empty area), the preceding text is highlighted due to range normalizing.
* If the selection ends on range endContainer (or startContainer in reverse select) but no text is selected (i.e. selection ends at
* an empty area), the preceding text is highlighted due to range normalizing.
* This is a visual bug and would sometimes lead to weird highlight behavior during removal.
*/
const selectionEndNode = selection.focusNode
const selectionEndOffset = selection.focusOffset
const selectionStartNode = isReverseSelected ? range.endContainer : range.startContainer
const selectionStartNode = isReverseSelected
? range.endContainer
: range.startContainer
if (selectionEndNode?.nodeType === Node.TEXT_NODE) {
const selectionEndNodeEdgeIndex = isReverseSelected ? selectionEndNode.textContent?.length : 0
const selectionEndNodeEdgeIndex = isReverseSelected
? selectionEndNode.textContent?.length
: 0
if (selectionStartNode !== selectionEndNode &&
selectionEndOffset == selectionEndNodeEdgeIndex) {
clipRangeToNearestAnchor(range, selectionEndNode, isReverseSelected)
if (
selectionStartNode !== selectionEndNode &&
selectionEndOffset == selectionEndNodeEdgeIndex
) {
clipRangeToNearestAnchor(range, selectionEndNode, isReverseSelected)
}
}
}
return isRangeAllowed ? { range, isReverseSelected, selection } : undefined
}
/**
* Clip selection range to the beginning/end of the adjacent anchor element
*
*
* @param range selection range
* @param selectionEndNode the node where the selection ended at
* @param isReverseSelected
* @param isReverseSelected
*/
const clipRangeToNearestAnchor = (
range: Range,
@ -279,30 +286,44 @@ const clipRangeToNearestAnchor = (
isReverseSelected: boolean
) => {
let nearestAnchorElement = selectionEndNode.parentElement
while (nearestAnchorElement !== null && !nearestAnchorElement.hasAttribute('data-omnivore-anchor-idx')) {
nearestAnchorElement = nearestAnchorElement.parentElement;
while (
nearestAnchorElement !== null &&
!nearestAnchorElement.hasAttribute('data-omnivore-anchor-idx')
) {
nearestAnchorElement = nearestAnchorElement.parentElement
}
if (!nearestAnchorElement) {
throw Error('Unable to find nearest anchor element for node: ' + selectionEndNode)
throw Error(
'Unable to find nearest anchor element for node: ' + selectionEndNode
)
}
let anchorId = Number(nearestAnchorElement.getAttribute('data-omnivore-anchor-idx')!)
let anchorId = Number(
nearestAnchorElement.getAttribute('data-omnivore-anchor-idx')!
)
let adjacentAnchorId, adjacentAnchor, adjacentAnchorOffset
if (isReverseSelected) {
// move down to find adjacent anchor node and clip at its beginning
adjacentAnchorId = anchorId + 1
adjacentAnchor = document.querySelectorAll(`[data-omnivore-anchor-idx='${adjacentAnchorId}']`)[0]
adjacentAnchor = document.querySelectorAll(
`[data-omnivore-anchor-idx='${adjacentAnchorId}']`
)[0]
adjacentAnchorOffset = 0
range.setStart(adjacentAnchor, adjacentAnchorOffset)
} else {
// move up to find adjacent anchor node and clip at its end
do {
adjacentAnchorId = --anchorId
adjacentAnchor = document.querySelectorAll(`[data-omnivore-anchor-idx='${adjacentAnchorId}']`)[0]
adjacentAnchor = document.querySelectorAll(
`[data-omnivore-anchor-idx='${adjacentAnchorId}']`
)[0]
} while (adjacentAnchor.contains(selectionEndNode))
if (adjacentAnchor.textContent) {
let lastTextNodeChild = adjacentAnchor.lastChild
while (!!lastTextNodeChild && lastTextNodeChild.nodeType !== Node.TEXT_NODE) {
lastTextNodeChild = lastTextNodeChild.previousSibling;
while (
!!lastTextNodeChild &&
lastTextNodeChild.nodeType !== Node.TEXT_NODE
) {
lastTextNodeChild = lastTextNodeChild.previousSibling
}
adjacentAnchor = lastTextNodeChild
adjacentAnchorOffset = adjacentAnchor?.nodeValue?.length ?? 0
@ -326,7 +347,7 @@ export type RangeEndPos = {
/**
* Return coordinates of the screen area occupied by the last line of user selection
*
*
* @param range range of user selection
* @param getFirst whether to get first line of user selection. Get last if false (default)
* @returns {RangeEndPos} selection coordinates

View File

@ -1,18 +1,24 @@
import { useCallback } from 'react'
import { setLinkArchivedMutation } from '../networking/mutations/setLinkArchivedMutation'
import {
showErrorToast,
showSuccessToast,
showSuccessToastWithUndo,
} from '../toastHelpers'
import { deleteLinkMutation } from '../networking/mutations/deleteLinkMutation'
import { updatePageMutation } from '../networking/mutations/updatePageMutation'
import { State } from '../networking/fragments/articleFragment'
import { moveToFolderMutation } from '../networking/mutations/moveToLibraryMutation'
import {
useArchiveItem,
useDeleteItem,
useMoveItemToFolder,
} from '../networking/library_items/useLibraryItems'
export default function useLibraryItemActions() {
const archiveItem = useCallback(async (itemId: string) => {
const result = await setLinkArchivedMutation({
const archiveItem = useArchiveItem()
const deleteItem = useDeleteItem()
const moveItem = useMoveItemToFolder()
const doArchiveItem = useCallback(async (itemId: string) => {
const result = await archiveItem.mutateAsync({
linkId: itemId,
archived: true,
})
@ -27,8 +33,8 @@ export default function useLibraryItemActions() {
return !!result
}, [])
const deleteItem = useCallback(async (itemId: string, undo: () => void) => {
const result = await deleteLinkMutation(itemId)
const doDeleteItem = useCallback(async (itemId: string, undo: () => void) => {
const result = await deleteItem.mutateAsync(itemId)
if (result) {
showSuccessToastWithUndo('Item removed', async () => {
@ -52,8 +58,8 @@ export default function useLibraryItemActions() {
return !!result
}, [])
const moveItem = useCallback(async (itemId: string) => {
const result = await moveToFolderMutation(itemId, 'inbox')
const doMoveItem = useCallback(async (itemId: string) => {
const result = await moveItem.mutateAsync({ itemId, folder: 'inbox' })
if (result) {
showSuccessToast('Moved to library', { position: 'bottom-right' })
} else {
@ -85,5 +91,10 @@ export default function useLibraryItemActions() {
[]
)
return { archiveItem, deleteItem, moveItem, shareItem }
return {
archiveItem: doArchiveItem,
deleteItem: doDeleteItem,
moveItem: doMoveItem,
shareItem,
}
}

View File

@ -0,0 +1,77 @@
import { gql } from 'graphql-request'
import { highlightFragment } from '../fragments/highlightFragment'
export const GQL_CREATE_HIGHLIGHT = gql`
mutation CreateHighlight($input: CreateHighlightInput!) {
createHighlight(input: $input) {
... on CreateHighlightSuccess {
highlight {
...HighlightFields
}
}
... on CreateHighlightError {
errorCodes
}
}
}
${highlightFragment}
`
export const GQL_DELETE_HIGHLIGHT = gql`
mutation DeleteHighlight($highlightId: ID!) {
deleteHighlight(highlightId: $highlightId) {
... on DeleteHighlightSuccess {
highlight {
id
}
}
... on DeleteHighlightError {
errorCodes
}
}
}
`
export const GQL_UPDATE_HIGHLIGHT = gql`
mutation UpdateHighlight($input: UpdateHighlightInput!) {
updateHighlight(input: $input) {
... on UpdateHighlightSuccess {
highlight {
id
}
}
... on UpdateHighlightError {
errorCodes
}
}
}
`
export const GQL_MERGE_HIGHLIGHT = gql`
mutation MergeHighlight($input: MergeHighlightInput!) {
mergeHighlight(input: $input) {
... on MergeHighlightSuccess {
highlight {
id
shortId
quote
prefix
suffix
patch
color
createdAt
updatedAt
annotation
sharedAt
createdByMe
}
overlapHighlightIdList
}
... on MergeHighlightError {
errorCodes
}
}
}
`

View File

@ -0,0 +1,209 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { gqlFetcher } from '../networkHelpers'
import {
GQL_CREATE_HIGHLIGHT,
GQL_DELETE_HIGHLIGHT,
GQL_MERGE_HIGHLIGHT,
GQL_UPDATE_HIGHLIGHT,
} from './gql'
import { updateItemProperty } from '../library_items/useLibraryItems'
import { Highlight, HighlightType } from '../fragments/highlightFragment'
import { UpdateHighlightInput } from '../mutations/updateHighlightMutation'
import { MergeHighlightInput } from '../mutations/mergeHighlightMutation'
export const useCreateHighlight = () => {
const queryClient = useQueryClient()
const createHighlight = async (variables: {
itemId: string
input: CreateHighlightInput
}) => {
const result = (await gqlFetcher(GQL_CREATE_HIGHLIGHT, {
input: variables.input,
})) as CreateHighlightData
if (result.createHighlight.errorCodes?.length) {
throw new Error(result.createHighlight.errorCodes[0])
}
return result.createHighlight.highlight
}
return useMutation({
mutationFn: createHighlight,
onSuccess: (newHighlight, variables) => {
if (newHighlight) {
updateItemProperty(queryClient, variables.itemId, (item) => {
return {
...item,
highlights: [...item.highlights, newHighlight],
}
})
}
},
})
}
export const useDeleteHighlight = () => {
const queryClient = useQueryClient()
const deleteHighlight = async (variables: {
itemId: string
highlightId: string
}) => {
const result = (await gqlFetcher(GQL_DELETE_HIGHLIGHT, {
highlightId: variables.highlightId,
})) as DeleteHighlightData
if (result.deleteHighlight.errorCodes?.length) {
throw new Error(result.deleteHighlight.errorCodes[0])
}
return result.deleteHighlight.highlight
}
return useMutation({
mutationFn: deleteHighlight,
onSuccess: (deletedHighlight, variables) => {
if (deletedHighlight) {
updateItemProperty(queryClient, variables.itemId, (item) => {
return {
...item,
highlights: item.highlights.filter(
(h) => h.id != deletedHighlight.id
),
}
})
}
},
})
}
export const useUpdateHighlight = () => {
const queryClient = useQueryClient()
const updateHighlight = async (variables: {
itemId: string
input: UpdateHighlightInput
}) => {
const result = (await gqlFetcher(GQL_UPDATE_HIGHLIGHT, {
input: variables.input,
})) as UpdateHighlightData
if (result.updateHighlight.errorCodes?.length) {
throw new Error(result.updateHighlight.errorCodes[0])
}
return result.updateHighlight.highlight
}
return useMutation({
mutationFn: updateHighlight,
onSuccess: (updatedHighlight, variables) => {
if (updatedHighlight) {
updateItemProperty(queryClient, variables.itemId, (item) => {
return {
...item,
highlights: [
...item.highlights.filter((h) => h.id != updatedHighlight.id),
updatedHighlight,
],
}
})
}
},
})
}
export const useMergeHighlight = () => {
const queryClient = useQueryClient()
const mergeHighlight = async (variables: {
itemId: string
input: MergeHighlightInput
}) => {
const result = (await gqlFetcher(GQL_MERGE_HIGHLIGHT, {
input: {
id: variables.input.id,
shortId: variables.input.shortId,
articleId: variables.input.articleId,
patch: variables.input.patch,
quote: variables.input.quote,
prefix: variables.input.prefix,
suffix: variables.input.suffix,
html: variables.input.html,
annotation: variables.input.annotation,
overlapHighlightIdList: variables.input.overlapHighlightIdList,
highlightPositionPercent: variables.input.highlightPositionPercent,
highlightPositionAnchorIndex:
variables.input.highlightPositionAnchorIndex,
},
})) as MergeHighlightData
if (result.mergeHighlight.errorCodes?.length) {
throw new Error(result.mergeHighlight.errorCodes[0])
}
return result.mergeHighlight
}
return useMutation({
mutationFn: mergeHighlight,
onSuccess: (mergeHighlights, variables) => {
if (mergeHighlights && mergeHighlights.highlight) {
const newHighlight = mergeHighlights.highlight
const mergedIds = mergeHighlights.overlapHighlightIdList ?? []
updateItemProperty(queryClient, variables.itemId, (item) => {
return {
...item,
highlights: [
...item.highlights.filter((h) => mergedIds.indexOf(h.id) == -1),
newHighlight,
],
}
})
}
},
})
}
type MergeHighlightData = {
mergeHighlight: MergeHighlightResult
}
type MergeHighlightResult = {
highlight?: Highlight
overlapHighlightIdList?: string[]
errorCodes?: string[]
}
type UpdateHighlightData = {
updateHighlight: UpdateHighlightResult
}
type UpdateHighlightResult = {
highlight?: Highlight
errorCodes?: string[]
}
type DeleteHighlightData = {
deleteHighlight: DeleteHighlightResult
}
type DeleteHighlightResult = {
highlight?: Highlight
errorCodes?: string[]
}
type CreateHighlightData = {
createHighlight: CreateHighlightResult
}
type CreateHighlightResult = {
highlight?: Highlight
errorCodes?: string[]
}
export type CreateHighlightInput = {
id: string
shortId: string
articleId: string
prefix?: string
suffix?: string
quote?: string
html?: string
color?: string
annotation?: string
patch?: string
highlightPositionPercent?: number
highlightPositionAnchorIndex?: number
type?: HighlightType
}

View File

@ -132,6 +132,19 @@ export const GQL_DELETE_LIBRARY_ITEM = gql`
}
`
export const GQL_MOVE_ITEM_TO_FOLDER = gql`
mutation MoveToFolder($id: ID!, $folder: String!) {
moveToFolder(id: $id, folder: $folder) {
... on MoveToFolderSuccess {
success
}
... on MoveToFolderError {
errorCodes
}
}
}
`
export const GQL_UPDATE_LIBRARY_ITEM = gql`
mutation UpdatePage($input: UpdatePageInput!) {
updatePage(input: $input) {

View File

@ -11,18 +11,15 @@ import { ContentReader, PageType, State } from '../fragments/articleFragment'
import { Highlight, highlightFragment } from '../fragments/highlightFragment'
import { makeGqlFetcher, requestHeaders } from '../networkHelpers'
import { Label } from '../fragments/labelFragment'
import { moveToFolderMutation } from '../mutations/moveToLibraryMutation'
import {
GQL_DELETE_LIBRARY_ITEM,
GQL_GET_LIBRARY_ITEM_CONTENT,
GQL_MOVE_ITEM_TO_FOLDER,
GQL_SEARCH_QUERY,
GQL_SET_LINK_ARCHIVED,
GQL_UPDATE_LIBRARY_ITEM,
} from './gql'
import { parseGraphQLResponse } from '../queries/gql-errors'
import { gqlEndpoint } from '../../appConfig'
import { GraphQLResponse } from 'graphql-request/dist/types'
import { ArticleAttributes } from '../queries/useGetArticleQuery'
function gqlFetcher(
query: string,
@ -63,25 +60,55 @@ const updateItemPropertyInCache = (
propertyName: string,
propertyValue: any
) => {
const setter = createDictionary(propertyName, propertyValue)
updateItemProperty(queryClient, itemId, (oldItem) => {
const setter = createDictionary(propertyName, propertyValue)
return {
...oldItem,
...setter,
}
})
}
export const updateItemProperty = (
queryClient: QueryClient,
itemId: string,
updateFunc: (input: ArticleAttributes) => ArticleAttributes
) => {
let foundItemSlug: string | undefined
const keys = queryClient
.getQueryCache()
.findAll({ queryKey: ['libraryItems'] })
keys.forEach((query) => {
queryClient.setQueryData(query.queryKey, (data: any) => {
if (!data) return data
return {
const updatedData = {
...data,
pages: data.pages.map((page: any) => ({
...page,
edges: page.edges.map((edge: any) =>
edge.node.id === itemId
? { ...edge, node: { ...edge.node, ...setter } }
: edge
),
edges: page.edges.map((edge: any) => {
if (edge.node.id === itemId) {
foundItemSlug = edge.node.slug
return {
...edge,
node: { ...edge.node, ...updateFunc(edge.node) },
}
}
return edge
}),
})),
}
return updatedData
})
if (foundItemSlug)
queryClient.setQueryData(
['libraryItem', foundItemSlug],
(oldData: ArticleAttributes) => {
return {
...oldData,
...updateFunc(oldData),
}
}
)
})
}
@ -93,7 +120,6 @@ const updateItemPropertiesInCache = (
const keys = queryClient
.getQueryCache()
.findAll({ queryKey: ['libraryItems'] })
console.log('updateItemPropertiesInCache::libraryItems: ', keys)
keys.forEach((query) => {
queryClient.setQueryData(query.queryKey, (data: any) => {
if (!data) return data
@ -243,6 +269,43 @@ export const useRestoreItem = () => {
})
}
export const useMoveItemToFolder = () => {
const queryClient = useQueryClient()
const restoreItem = async (variables: { itemId: string; folder: string }) => {
const result = (await gqlFetcher(GQL_MOVE_ITEM_TO_FOLDER, {
id: variables.itemId,
folder: variables.folder,
})) as UpdateLibraryItemData
if (result.updatePage.errorCodes?.length) {
throw new Error(result.updatePage.errorCodes[0])
}
return result.updatePage
}
return useMutation({
mutationFn: restoreItem,
onMutate: async (variables: { itemId: string; folder: string }) => {
await queryClient.cancelQueries({ queryKey: ['libraryItems'] })
updateItemPropertyInCache(
queryClient,
variables.itemId,
'folder',
variables.folder
)
return { previousItems: queryClient.getQueryData(['libraryItems']) }
},
onError: (error, itemId, context) => {
if (context?.previousItems) {
queryClient.setQueryData(['libraryItems'], context.previousItems)
}
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: ['libraryItems'],
})
},
})
}
export const useUpdateItemReadStatus = () => {
const queryClient = useQueryClient()
const updateItemReadStatus = async (
@ -291,11 +354,6 @@ export const useGetLibraryItemContent = (username: string, slug: string) => {
return useQuery({
queryKey: ['libraryItem', slug],
queryFn: async () => {
console.log('input: ', {
slug,
username,
includeFriendsHighlights: false,
})
const response = (await gqlFetcher(GQL_GET_LIBRARY_ITEM_CONTENT, {
slug,
username,
@ -313,6 +371,36 @@ export const useGetLibraryItemContent = (username: string, slug: string) => {
})
}
export type TextDirection = 'RTL' | 'LTR'
export type ArticleAttributes = {
id: string
title: string
url: string
originalArticleUrl: string
author?: string
image?: string
savedAt: string
createdAt: string
publishedAt?: string
description?: string
wordsCount?: number
contentReader: ContentReader
readingProgressPercent: number
readingProgressTopPercent?: number
readingProgressAnchorIndex: number
slug: string
folder: string
savedByViewer?: boolean
content: string
highlights: Highlight[]
linkId: string
labels?: Label[]
state?: State
directionality?: TextDirection
recommendations?: Recommendation[]
}
type ArticleResult = {
article?: ArticleAttributes
errorCodes?: string[]

View File

@ -5,26 +5,7 @@ import {
highlightFragment,
HighlightType,
} from './../fragments/highlightFragment'
export type CreateHighlightInput = {
id: string
shortId: string
articleId: string
prefix?: string
suffix?: string
quote?: string
html?: string
color?: string
annotation?: string
patch?: string
highlightPositionPercent?: number
highlightPositionAnchorIndex?: number
type?: HighlightType
}
import { CreateHighlightInput } from '../highlights/useItemHighlights'
type CreateHighlightOutput = {
createHighlight: InnerCreateHighlightOutput

View File

@ -1,28 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export async function deleteLinkMutation(
linkId: string
): Promise<unknown> {
const mutation = gql`
mutation SetBookmarkArticle($input: SetBookmarkArticleInput!) {
setBookmarkArticle(input: $input) {
... on SetBookmarkArticleSuccess {
bookmarkedArticle {
id
}
}
... on SetBookmarkArticleError {
errorCodes
}
}
}`
try {
const data = await gqlFetcher(mutation, { input: { articleID: linkId, bookmark: false }})
return data
} catch (error) {
console.log('SetBookmarkArticleOutput error', error)
return undefined
}
}

View File

@ -1,41 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
type MoveToFolderResponseData = {
success?: boolean
errorCodes?: string[]
}
type MoveToFolderResponse = {
moveToFolder?: MoveToFolderResponseData
}
export async function moveToFolderMutation(
itemId: string,
folder: string
): Promise<boolean> {
const mutation = gql`
mutation MoveToFolder($id: ID!, $folder: String!) {
moveToFolder(id: $id, folder: $folder) {
... on MoveToFolderSuccess {
success
}
... on MoveToFolderError {
errorCodes
}
}
}
`
try {
const response = await gqlFetcher(mutation, { id: itemId, folder })
const data = response as MoveToFolderResponse | undefined
if (data?.moveToFolder?.errorCodes) {
return false
}
return data?.moveToFolder?.success ?? false
} catch (error) {
console.log('MoveToFolder error', error)
return false
}
}

View File

@ -1,34 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
type SetLinkArchivedInput = {
linkId: string
archived: boolean
}
export async function setLinkArchivedMutation(
input: SetLinkArchivedInput
): Promise<Record<string, never> | undefined> {
const mutation = gql`
mutation SetLinkArchived($input: ArchiveLinkInput!) {
setLinkArchived(input: $input) {
... on ArchiveLinkSuccess {
linkId
message
}
... on ArchiveLinkError {
message
errorCodes
}
}
}
`
try {
const data = await gqlFetcher(mutation, { input })
return data as Record<string, never> | undefined
} catch (error) {
console.log('SetLinkArchivedInput error', error)
return undefined
}
}

View File

@ -1,34 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
type ShareHighlightCommentMutationInput = {
highlightId: string
annotation?: string
}
export async function shareHighlightCommentMutation(
input: ShareHighlightCommentMutationInput
): Promise<boolean> {
const mutation = gql`
mutation UpdateHighlight($input: UpdateHighlightInput!) {
updateHighlight(input: $input) {
... on UpdateHighlightSuccess {
highlight {
id
}
}
... on UpdateHighlightError {
errorCodes
}
}
}
`
try {
await gqlFetcher(mutation, { input })
return true
} catch {
return false
}
}

View File

@ -1,6 +1,7 @@
import { gql } from 'graphql-request'
import useSWRImmutable from 'swr'
import { makeGqlFetcher, RequestContext, ssrFetcher } from '../networkHelpers'
import { ArticleAttributes } from '../library_items/useLibraryItems'
type ArticleQueryInput = {
username?: string
@ -17,11 +18,6 @@ type NestedArticleData = {
errorCodes?: string[]
}
export type ArticleAttributes = {
id: string
originalHtml: string
}
const query = gql`
query GetArticle($username: String!, $slug: String!) {
article(username: $username, slug: $slug) {

View File

@ -9,7 +9,11 @@ import {
import { Highlight, highlightFragment } from '../fragments/highlightFragment'
import { ScopedMutator } from 'swr/dist/_internal'
import { Label, labelFragment } from '../fragments/labelFragment'
import { LibraryItems, Recommendation } from '../library_items/useLibraryItems'
import {
ArticleAttributes,
LibraryItems,
Recommendation,
} from '../library_items/useLibraryItems'
import useSWR from 'swr'
import { recommendationFragment } from '../library_items/gql'
@ -36,36 +40,6 @@ type NestedArticleData = {
errorCodes?: string[]
}
export type TextDirection = 'RTL' | 'LTR'
export type ArticleAttributes = {
id: string
title: string
url: string
originalArticleUrl: string
author?: string
image?: string
savedAt: string
createdAt: string
publishedAt?: string
description?: string
wordsCount?: number
contentReader: ContentReader
readingProgressPercent: number
readingProgressTopPercent?: number
readingProgressAnchorIndex: number
slug: string
folder: string
savedByViewer?: boolean
content: string
highlights: Highlight[]
linkId: string
labels?: Label[]
state?: State
directionality?: TextDirection
recommendations?: Recommendation[]
}
const query = gql`
query GetArticle(
$username: String!

View File

@ -3,7 +3,7 @@ import useSWR from 'swr'
import { articleFragment } from '../fragments/articleFragment'
import { highlightFragment } from '../fragments/highlightFragment'
import { makeGqlFetcher } from '../networkHelpers'
import { ArticleAttributes } from './useGetArticleQuery'
import { ArticleAttributes } from '../library_items/useLibraryItems'
type ArticleSavingStatusInput = {
id?: string

View File

@ -1,5 +1,4 @@
import { useRouter } from 'next/router'
import { useGetArticleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
import { applyStoredTheme } from '../../../lib/themeUpdater'
import { useMemo } from 'react'
import {
@ -7,6 +6,7 @@ import {
HStack,
SpanBox,
} from '../../../components/elements/LayoutPrimitives'
import { useGetLibraryItemContent } from '../../../lib/networking/library_items/useLibraryItems'
type ArticleAttribute = {
name: string
@ -15,11 +15,10 @@ type ArticleAttribute = {
export default function Debug(): JSX.Element {
const router = useRouter()
const { articleData, articleFetchError, isLoading } = useGetArticleQuery({
username: router.query.username as string,
slug: router.query.slug as string,
includeFriendsHighlights: false,
})
const { data: article } = useGetLibraryItemContent(
router.query.username as string,
router.query.slug as string
)
applyStoredTheme()
@ -30,13 +29,11 @@ export default function Debug(): JSX.Element {
// return sortedAttributes.sort((a, b) =>
// a.createdAt.localeCompare(b.createdAt)
// )
if (!articleData?.article.article) {
if (!article) {
return []
}
const result: ArticleAttribute[] = []
const article = articleData.article.article
result.push({ name: 'id', value: article.id })
result.push({ name: 'linkId', value: article.linkId })
@ -171,7 +168,7 @@ export default function Debug(): JSX.Element {
// recommendations?: Recommendation[]
return result
}, [articleData])
}, [article])
return (
<>

View File

@ -39,6 +39,14 @@ import {
useGetLibraryItemContent,
useUpdateItemReadStatus,
} from '../../../lib/networking/library_items/useLibraryItems'
import {
CreateHighlightInput,
useCreateHighlight,
useDeleteHighlight,
useMergeHighlight,
useMergeHighlights,
useUpdateHighlight,
} from '../../../lib/networking/highlights/useItemHighlights'
const PdfArticleContainerNoSSR = dynamic<PdfArticleContainerProps>(
() => import('./../../../components/templates/article/PdfArticleContainer'),
@ -59,13 +67,16 @@ export default function Reader(): JSX.Element {
const archiveItem = useArchiveItem()
const deleteItem = useDeleteItem()
const updateItemReadStatus = useUpdateItemReadStatus()
const createHighlight = useCreateHighlight()
const deleteHighlight = useDeleteHighlight()
const updateHighlight = useUpdateHighlight()
const mergeHighlight = useMergeHighlight()
const { data: libraryItem, error: articleFetchError } =
useGetLibraryItemContent(
router.query.username as string,
router.query.slug as string
)
console.log('articleFetchError: ', articleFetchError)
useEffect(() => {
dispatchLabels({
type: 'RESET',
@ -574,10 +585,59 @@ export default function Reader(): JSX.Element {
libraryItem.directionality ?? readerSettings.textDirection
}
articleMutations={{
createHighlightMutation,
deleteHighlightMutation,
mergeHighlightMutation,
updateHighlightMutation,
createHighlightMutation: async (
input: CreateHighlightInput
) => {
try {
const result = await createHighlight.mutateAsync({
itemId: libraryItem.id,
input,
})
return result
} catch (err) {
console.log('error creating highlight', err)
return undefined
}
},
deleteHighlightMutation: async (
libraryItemId,
highlightId: string
) => {
try {
await deleteHighlight.mutateAsync({
itemId: libraryItem.id,
highlightId,
})
return true
} catch (err) {
console.log('error deleting highlight', err)
return false
}
},
mergeHighlightMutation: async (input) => {
try {
const result = await mergeHighlight.mutateAsync({
itemId: libraryItem.id,
input,
})
return result?.highlight
} catch (err) {
console.log('error merging highlight', err)
return undefined
}
},
updateHighlightMutation: async (input) => {
try {
const result = await updateHighlight.mutateAsync({
itemId: libraryItem.id,
input,
})
return result?.id
} catch (err) {
console.log('error updating highlight', err)
return undefined
}
},
articleReadingProgressMutation: async (
input: ArticleReadingProgressMutationInput
) => {

View File

@ -4,7 +4,6 @@ import { Box } from '../../../../components/elements/LayoutPrimitives'
import { useGetArticleSavingStatus } from '../../../../lib/networking/queries/useGetArticleSavingStatus'
import { ErrorComponent } from '../../../../components/templates/SavingRequest'
import { useSWRConfig } from 'swr'
import { cacheArticle } from '../../../../lib/networking/queries/useGetArticleQuery'
import { PrimaryLayout } from '../../../../components/templates/PrimaryLayout'
import { applyStoredTheme } from '../../../../lib/themeUpdater'
@ -81,10 +80,6 @@ function PrimaryContent(props: PrimaryContentProps): JSX.Element {
)
}
if (article) {
cacheArticle(mutate, props.username, article)
}
if (successRedirectPath) {
router.replace(
`/app${successRedirectPath}?isAppEmbedView=true&highlightBarDisabled=true`

View File

@ -1,352 +0,0 @@
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 (
<Box
css={{
width: '100%',
height: `100vh`,
}}
>
<EmptyHighlights />
</Box>
)
}
return (
<HStack>
<Toaster />
<LibraryFilterMenu
setShowAddLinkModal={setShowAddLinkModal}
showFilterMenu={showFilterMenu}
setShowFilterMenu={setShowFilterMenu}
searchTerm={undefined}
applySearchQuery={(searchQuery: string) => {
router?.push(`/home?q=${searchQuery}`)
}}
/>
<VStack
css={{
maxWidth: '70%',
padding: '20px',
margin: '30px 50px 0 0',
}}
>
{highlights.map((highlight) => {
return (
viewer.viewerData?.me && (
<HighlightCard
key={highlight.id}
highlight={highlight}
viewer={viewer.viewerData.me}
router={router}
mutate={mutate}
/>
)
)
})}
</VStack>
</HStack>
)
}
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 (
<HighlightViewNote
targetId={highlight.id}
text={annotation}
placeHolder="Add notes to this highlight..."
highlight={highlight}
mode={noteMode}
setEditMode={setNoteMode}
updateHighlight={(highlight) => {
setAnnotation(highlight.annotation)
}}
/>
)
}
function HighlightCard(props: HighlightCardProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false)
const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] =
useState<undefined | string>(undefined)
const [labelsTarget, setLabelsTarget] = useState<Highlight | undefined>(
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 (
<VStack
ref={refs.setReference}
{...getReferenceProps()}
css={{
width: '100%',
fontFamily: '$inter',
padding: '20px',
marginBottom: '20px',
bg: '$thBackground2',
borderRadius: '8px',
cursor: 'pointer',
'&:hover': {
backgroundColor: '$thBackground3',
},
}}
>
<Box
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
<HighlightHoverActions
viewer={props.viewer}
highlight={props.highlight}
isHovered={isOpen ?? false}
viewInReader={viewInReader}
setLabelsTarget={setLabelsTarget}
setShowConfirmDeleteHighlightId={setShowConfirmDeleteHighlightId}
/>
</Box>
<Box
css={{
width: '30px',
height: '5px',
backgroundColor: highlightColor(props.highlight.color),
borderRadius: '2px',
}}
/>
<Box
css={{
color: '$thText',
fontSize: '11px',
marginTop: '10px',
fontWeight: 300,
}}
>
{timeAgo(props.highlight.updatedAt)}
</Box>
{props.highlight.quote && (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{props.highlight.quote}
</ReactMarkdown>
)}
<HighlightAnnotation highlight={props.highlight} />
{props.highlight.labels && (
<HStack
css={{
marginBottom: '10px',
}}
>
{props.highlight.labels.map((label) => {
return (
<LabelChip key={label.id} color={label.color} text={label.name} />
)
})}
</HStack>
)}
<Box
css={{
color: '$thText',
fontSize: '12px',
lineHeight: '20px',
fontWeight: 300,
marginBottom: '10px',
}}
>
{props.highlight.libraryItem?.title}
</Box>
<Box
css={{
color: '$grayText',
fontSize: '12px',
lineHeight: '20px',
fontWeight: 300,
}}
>
{props.highlight.libraryItem?.author}
</Box>
{showConfirmDeleteHighlightId && (
<ConfirmationModal
message={'Are you sure you want to delete this highlight?'}
onAccept={() => {
;(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={
<TrashIcon
size={40}
color={theme.colors.grayTextContrast.toString()}
/>
}
/>
)}
{labelsTarget && (
<SetHighlightLabelsModalPresenter
highlight={labelsTarget}
highlightId={labelsTarget.id}
onUpdate={(highlight) => {
// Don't actually need to do something here
console.log('update highlight: ', highlight)
}}
onOpenChange={() => {
props.mutate()
setLabelsTarget(undefined)
}}
/>
)}
</VStack>
)
}