Merge pull request #4223 from omnivore-app/fix/web-library-fixes

Web library cleanups
This commit is contained in:
Jackson Harper 2024-08-13 15:14:56 +08:00 committed by GitHub
commit d56f4f9234
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
124 changed files with 4469 additions and 5802 deletions

View file

@ -2,16 +2,17 @@ import AutosizeInput_, { AutosizeInputProps } from 'react-input-autosize'
import { Box, SpanBox } from './LayoutPrimitives'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Label } from '../../lib/networking/fragments/labelFragment'
import { useGetLabelsQuery } from '../../lib/networking/queries/useGetLabelsQuery'
import { isTouchScreenDevice } from '../../lib/deviceType'
import { EditLabelChip } from './EditLabelChip'
import { LabelsDispatcher } from '../../lib/hooks/useSetPageLabels'
import { EditLabelChipStack } from './EditLabelChipStack'
import { useGetLabels } from '../../lib/networking/labels/useLabels'
// AutosizeInput is a Class component, but the types are broken in React 18.
// TODO: Maybe move away from this component, since it hasn't been updated for 3 years.
// https://github.com/JedWatson/react-input-autosize/issues
const AutosizeInput = AutosizeInput_ as unknown as React.FunctionComponent<AutosizeInputProps>
const AutosizeInput =
AutosizeInput_ as unknown as React.FunctionComponent<AutosizeInputProps>
const MaxUnstackedLabels = 7
@ -40,7 +41,7 @@ type LabelsPickerProps = {
export const LabelsPicker = (props: LabelsPickerProps): JSX.Element => {
const inputRef = useRef<HTMLInputElement | null>()
const availableLabels = useGetLabelsQuery()
const { data: availableLabels } = useGetLabels()
const [isStackExpanded, setIsStackExpanded] = useState(false)
const {
focused,
@ -80,9 +81,10 @@ export const LabelsPicker = (props: LabelsPickerProps): JSX.Element => {
setTabCount(_tabCount)
}
const matches = availableLabels.labels.filter((l) =>
l.name.toLowerCase().startsWith(_tabStartValue)
)
const matches =
availableLabels?.filter((l) =>
l.name.toLowerCase().startsWith(_tabStartValue)
) ?? []
if (_tabCount < matches.length) {
setInputValue(matches[_tabCount].name)

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

@ -0,0 +1,35 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class UntrashIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
strokeWidth="1.5"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 3l18 18" />
<path d="M4 7h3m4 0h9" />
<path d="M10 11l0 6" />
<path d="M14 14l0 3" />
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l.077 -.923" />
<path d="M18.384 14.373l.616 -7.373" />
<path d="M9 5v-1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
</svg>
)
}
}

View file

@ -1,6 +1,7 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import { DEFAULT_HOME_PATH } from '../../../lib/navigations'
import { Box } from '../LayoutPrimitives'
export type OmnivoreLogoBaseProps = {
color?: string
href?: string
@ -9,13 +10,8 @@ export type OmnivoreLogoBaseProps = {
}
export function OmnivoreLogoBase(props: OmnivoreLogoBaseProps): JSX.Element {
const href = props.href || '/home'
const router = useRouter()
return (
<Link
passHref
href={href}
<Box
style={{
textDecoration: 'none',
display: 'flex',
@ -38,6 +34,6 @@ export function OmnivoreLogoBase(props: OmnivoreLogoBaseProps): JSX.Element {
aria-label="Omnivore logo"
>
{props.children}
</Link>
</Box>
)
}

View file

@ -31,6 +31,7 @@ import { highlightColor } from '../../lib/themeUpdater'
import { HighlightViewNote } from '../patterns/HighlightNotes'
import { theme } from '../tokens/stitches.config'
import { useDeleteHighlight } from '../../lib/networking/highlights/useItemHighlights'
const PAGE_SIZE = 10
@ -131,8 +132,10 @@ 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 [labelsTarget, setLabelsTarget] = useState<Highlight | undefined>(
undefined
)
const deleteHighlight = useDeleteHighlight()
const viewInReader = useCallback(
(highlightId: string) => {
@ -283,24 +286,22 @@ function HighlightCard(props: HighlightCardProps): JSX.Element {
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',
if (props.highlight.libraryItem) {
const success = await deleteHighlight.mutateAsync({
itemId: props.highlight.libraryItem?.id,
slug: props.highlight.libraryItem?.slug,
highlightId: showConfirmDeleteHighlightId,
})
if (success) {
showSuccessToast('Highlight deleted.', {
position: 'bottom-right',
})
} else {
showErrorToast('Error deleting highlight', {
position: 'bottom-right',
})
}
}
})()
setShowConfirmDeleteHighlightId(undefined)

View file

@ -37,7 +37,7 @@ import { Toaster } from 'react-hot-toast'
import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery'
import useLibraryItemActions from '../../lib/hooks/useLibraryItemActions'
import { SyncLoader } from 'react-spinners'
import { useGetRawSearchItemsQuery } from '../../lib/networking/queries/useGetLibraryItemsQuery'
import { useGetLibraryItems } from '../../lib/networking/library_items/useLibraryItems'
import { useRegisterActions } from 'kbar'
type HomeState = {
@ -182,8 +182,9 @@ type NavigationContextType = {
dispatch: React.Dispatch<Action>
}
const NavigationContext =
createContext<NavigationContextType | undefined>(undefined)
const NavigationContext = createContext<NavigationContextType | undefined>(
undefined
)
export const useNavigation = (): NavigationContextType => {
const context = useContext(NavigationContext)
@ -210,13 +211,15 @@ export function HomeContainer(): JSX.Element {
const shouldFallback =
homeData.error || (!homeData.isValidating && !hasTopPicks(homeData))
const searchData = useGetRawSearchItemsQuery(
const searchData = useGetLibraryItems(
undefined,
{
limit: 10,
searchQuery: 'in:inbox',
includeContent: false,
sortDescending: true,
},
// only enable this search if we didn't get home data
shouldFallback
)
@ -227,27 +230,28 @@ export function HomeContainer(): JSX.Element {
}, [viewerData])
const searchItems = useMemo(() => {
return searchData.items.map((item) => {
return {
id: item.id,
date: item.savedAt,
title: item.title,
url: item.url,
slug: item.slug,
score: 1.0,
thumbnail: item.image,
previewContent: item.description,
source: {
name: item.folder == 'following' ? item.subscription : item.siteName,
icon: item.siteIcon,
type: 'LIBRARY',
},
canArchive: true,
canDelete: true,
canShare: true,
canMove: item.folder == 'following',
} as HomeItem
})
return []
// return searchData.items.map((item) => {
// return {
// id: item.id,
// date: item.savedAt,
// title: item.title,
// url: item.url,
// slug: item.slug,
// score: 1.0,
// thumbnail: item.image,
// previewContent: item.description,
// source: {
// name: item.folder == 'following' ? item.subscription : item.siteName,
// icon: item.siteIcon,
// type: 'LIBRARY',
// },
// canArchive: true,
// canDelete: true,
// canShare: true,
// canMove: item.folder == 'following',
// } as HomeItem
// })
}, [searchData])
useEffect(() => {
@ -383,7 +387,7 @@ export function HomeContainer(): JSX.Element {
)
const dataReady =
!homeData.isValidating && (!shouldFallback || !searchData.isValidating)
!homeData.isValidating && (!shouldFallback || !searchData.isLoading)
if (!dataReady || (homeData.error && homeData.errorMessage == 'PENDING')) {
console.log('showing pending')
return (
@ -544,7 +548,7 @@ const JustAddedHomeSection = (props: HomeSectionProps): JSX.Element => {
<Button
style="link"
onClick={(event) => {
router.push('/l/library')
router.push('/library')
event.preventDefault()
}}
css={{
@ -946,12 +950,12 @@ const TopPicksItemView = (props: HomeItemViewProps): JSX.Element => {
useLibraryItemActions()
const doArchiveItem = useCallback(
async (libraryItemId: string) => {
async (libraryItemId: string, slug: string) => {
dispatch({
type: 'REMOVE_ITEM',
payload: libraryItemId,
})
if (!(await archiveItem(libraryItemId))) {
if (!(await archiveItem(libraryItemId, slug))) {
// dispatch({
// type: 'REPLACE_ITEM',
// itemId: libraryItemId,
@ -962,7 +966,7 @@ const TopPicksItemView = (props: HomeItemViewProps): JSX.Element => {
)
const doDeleteItem = useCallback(
async (libraryItemId: string) => {
async (libraryItemId: string, slug: string) => {
dispatch({
type: 'REMOVE_ITEM',
payload: libraryItemId,
@ -973,7 +977,7 @@ const TopPicksItemView = (props: HomeItemViewProps): JSX.Element => {
// : libraryItemId,
// })
}
if (!(await deleteItem(libraryItemId, undo))) {
if (!(await deleteItem(libraryItemId, slug, undo))) {
// dispatch({
// type: 'REPLACE_ITEM',
// payload: libraryItemId,
@ -984,12 +988,12 @@ const TopPicksItemView = (props: HomeItemViewProps): JSX.Element => {
)
const doMoveItem = useCallback(
async (libraryItemId: string) => {
async (libraryItemId: string, slug: string) => {
dispatch({
type: 'REMOVE_ITEM',
payload: libraryItemId,
})
if (!(await moveItem(libraryItemId))) {
if (!(await moveItem(libraryItemId, slug))) {
// dispatch({
// type: 'REPLACE_ITEM',
// payload: libraryItemId,
@ -1039,13 +1043,13 @@ const TopPicksItemView = (props: HomeItemViewProps): JSX.Element => {
;(event.target as HTMLElement).click()
break
case 'e':
doArchiveItem(props.homeItem.id)
doArchiveItem(props.homeItem.id, props.homeItem.slug)
break
case '#':
doDeleteItem(props.homeItem.id)
doDeleteItem(props.homeItem.id, props.homeItem.slug)
break
case 'm':
doMoveItem(props.homeItem.id)
doMoveItem(props.homeItem.id, props.homeItem.slug)
break
case 'o':
window.open(props.homeItem.url, '_blank')
@ -1093,8 +1097,7 @@ const TopPicksItemView = (props: HomeItemViewProps): JSX.Element => {
onClick={async (event) => {
event.preventDefault()
event.stopPropagation()
await doMoveItem(props.homeItem.id)
await doMoveItem(props.homeItem.id, props.homeItem.slug)
}}
>
<AddToLibraryActionIcon />
@ -1107,8 +1110,7 @@ const TopPicksItemView = (props: HomeItemViewProps): JSX.Element => {
onClick={async (event) => {
event.preventDefault()
event.stopPropagation()
await doArchiveItem(props.homeItem.id)
await doArchiveItem(props.homeItem.id, props.homeItem.slug)
}}
>
<ArchiveActionIcon />
@ -1121,8 +1123,7 @@ const TopPicksItemView = (props: HomeItemViewProps): JSX.Element => {
onClick={async (event) => {
event.preventDefault()
event.stopPropagation()
await doDeleteItem(props.homeItem.id)
await doDeleteItem(props.homeItem.id, props.homeItem.slug)
}}
>
<RemoveActionIcon />

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

@ -1,11 +1,13 @@
import type { ReactNode } from 'react'
import { Dropdown, DropdownOption } from '../elements/DropdownElements'
import { LibraryItemNode } from '../../lib/networking/queries/useGetLibraryItemsQuery'
import {
LibraryItemNode,
useUpdateItemReadStatus,
} from '../../lib/networking/library_items/useLibraryItems'
import { UserBasicData } from '../../lib/networking/queries/useGetViewerQuery'
import { State } from '../../lib/networking/fragments/articleFragment'
export type CardMenuDropdownAction =
| 'mark-read'
| 'mark-unread'
| 'archive'
| 'unarchive'
| 'delete'
@ -24,12 +26,15 @@ type CardMenuProps = {
}
export function CardMenu(props: CardMenuProps): JSX.Element {
const updateItemReadStatus = useUpdateItemReadStatus()
return (
<Dropdown
triggerElement={props.triggerElement}
onOpenChange={props.onOpenChange}
css={{ bg: '$thNavMenuFooter' }}
>
{!props.item.isArchived ? (
{props.item.state != State.ARCHIVED ? (
<DropdownOption
onSelect={() => props.actionHandler('archive')}
title="Archive"
@ -62,15 +67,31 @@ export function CardMenu(props: CardMenuProps): JSX.Element {
/>
{props.item.readingProgressPercent < 98 ? (
<DropdownOption
onSelect={() => {
props.actionHandler('mark-read')
onSelect={async () => {
await updateItemReadStatus.mutateAsync({
itemId: props.item.id,
slug: props.item.slug,
input: {
id: props.item.id,
readingProgressPercent: 100,
force: true,
},
})
}}
title="Mark read"
/>
) : (
<DropdownOption
onSelect={() => {
props.actionHandler('mark-unread')
onSelect={async () => {
await updateItemReadStatus.mutateAsync({
itemId: props.item.id,
slug: props.item.slug,
input: {
id: props.item.id,
readingProgressPercent: 0,
force: true,
},
})
}}
title="Mark unread"
/>

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

@ -14,7 +14,7 @@ import { HighlightViewNote } from './HighlightNotes'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { highlightColorVar } from '../../lib/themeUpdater'
import { ReadableItem } from '../../lib/networking/queries/useGetLibraryItemsQuery'
import { ReadableItem } from '../../lib/networking/library_items/useLibraryItems'
import { UserBasicData } from '../../lib/networking/queries/useGetViewerQuery'
import {
autoUpdate,

View file

@ -1,6 +1,6 @@
import { LayoutType } from '../../templates/homeFeed/HomeFeedContainer'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import type { LibraryItemNode } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import type { LibraryItemNode } from '../../../lib/networking/library_items/useLibraryItems'
import { MultiSelectMode } from '../../templates/homeFeed/LibraryHeader'
export type LinkedItemCardAction =
@ -18,6 +18,7 @@ export type LinkedItemCardAction =
| 'update-item'
| 'move-to-inbox'
| 'refresh'
| 'restore'
export type LinkedItemCardProps = {
item: LibraryItemNode

View file

@ -1,7 +1,7 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useMemo } from 'react'
import { LibraryItemNode } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { LibraryItemNode } from '../../../lib/networking/library_items/useLibraryItems'
import { HStack, SpanBox } from '../../elements/LayoutPrimitives'
import { RecommendedFlairIcon } from '../../elements/icons/RecommendedFlairIcon'
import { PinnedFlairIcon } from '../../elements/icons/PinnedFlairIcon'
@ -140,9 +140,7 @@ type FlairIconProps = {
children: React.ReactNode
}
export function FlairIcon(
props: FlairIconProps
): JSX.Element {
export function FlairIcon(props: FlairIconProps): JSX.Element {
return (
<SpanBox title={props.title} css={{ lineHeight: '1' }}>
{props.children}

View file

@ -4,7 +4,7 @@ import { CaretDown, CaretUp } from '@phosphor-icons/react'
import { MetaStyle, timeAgo, TitleStyle } from './LibraryCardStyles'
import { styled } from '@stitches/react'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { LibraryItemNode } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { LibraryItemNode } from '../../../lib/networking/library_items/useLibraryItems'
import { Button } from '../../elements/Button'
import { theme } from '../../tokens/stitches.config'
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'

View file

@ -1,6 +1,11 @@
import { useState } from 'react'
import { Box, SpanBox } from '../../elements/LayoutPrimitives'
import { LibraryItemNode } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import {
LibraryItemNode,
useArchiveItem,
useDeleteItem,
useRestoreItem,
} from '../../../lib/networking/library_items/useLibraryItems'
import { LinkedItemCardAction } from './CardTypes'
import { Button } from '../../elements/Button'
import { theme } from '../../tokens/stitches.config'
@ -14,6 +19,8 @@ import { LabelIcon } from '../../elements/icons/LabelIcon'
import { UnarchiveIcon } from '../../elements/icons/UnarchiveIcon'
import { BrowserIcon } from '../../elements/icons/BrowserIcon'
import { MoveToInboxIcon } from '../../elements/icons/MoveToInboxIcon'
import { UntrashIcon } from '../../elements/icons/UntrashIcon'
import { State } from '../../../lib/networking/fragments/articleFragment'
type LibraryHoverActionsProps = {
viewer: UserBasicData
@ -26,6 +33,9 @@ type LibraryHoverActionsProps = {
export const LibraryHoverActions = (props: LibraryHoverActionsProps) => {
const [menuOpen, setMenuOpen] = useState(false)
const archiveItem = useArchiveItem()
const deleteItem = useDeleteItem()
const restoreItem = useRestoreItem()
return (
<Box
@ -89,16 +99,26 @@ export const LibraryHoverActions = (props: LibraryHoverActionsProps) => {
</Button>
) : (
<Button
title={props.item.isArchived ? 'Unarchive (e)' : 'Archive (e)'}
title={
props.item.state === State.ARCHIVED
? 'Unarchive (e)'
: 'Archive (e)'
}
style="hoverActionIcon"
onClick={(event) => {
const action = props.item.isArchived ? 'unarchive' : 'archive'
props.handleAction(action)
onClick={async (event) => {
await archiveItem.mutateAsync({
itemId: props.item.id,
slug: props.item.slug,
input: {
linkId: props.item.id,
archived: props.item.state !== State.ARCHIVED,
},
})
event.preventDefault()
event.stopPropagation()
}}
>
{props.item.isArchived ? (
{props.item.state === State.ARCHIVED ? (
<UnarchiveIcon
size={21}
color={theme.colors.thNotebookSubtle.toString()}
@ -112,15 +132,35 @@ export const LibraryHoverActions = (props: LibraryHoverActionsProps) => {
</Button>
)}
<Button
title="Remove (#)"
title={props.item.state == State.DELETED ? 'Restore' : 'Remove (#)'}
style="hoverActionIcon"
onClick={(event) => {
props.handleAction('delete')
onClick={async (event) => {
if (props.item.state == State.DELETED) {
await restoreItem.mutateAsync({
itemId: props.item.id,
slug: props.item.slug,
})
} else {
await deleteItem.mutateAsync({
itemId: props.item.id,
slug: props.item.slug,
})
}
event.preventDefault()
event.stopPropagation()
}}
>
<TrashIcon size={21} color={theme.colors.thNotebookSubtle.toString()} />
{props.item.state == State.DELETED ? (
<UntrashIcon
size={21}
color={theme.colors.thNotebookSubtle.toString()}
/>
) : (
<TrashIcon
size={21}
color={theme.colors.thNotebookSubtle.toString()}
/>
)}
</Button>
<Button
title="Edit labels (l)"

View file

@ -85,12 +85,15 @@ export function LibraryListCard(props: LinkedItemCardProps): JSX.Element {
height: '100%',
cursor: 'pointer',
gap: '10px',
borderStyle: 'none',
borderBottom: 'none',
borderRadius: '6px',
borderBottom: props.legacyLayout
? 'unset'
: '1px solid $thLeftMenuBackground',
'@media (max-width: 930px)': {
borderRadius: '0px',
},
'&:hover': {
borderBottom: 'unset',
},
...layoutWidths,
}}
alignment="start"

View file

@ -4,9 +4,12 @@ import {
DropdownOption,
DropdownSeparator,
} from '../elements/DropdownElements'
import { ArticleAttributes } from '../../lib/networking/library_items/useLibraryItems'
import { State } from '../../lib/networking/fragments/articleFragment'
type DropdownMenuProps = {
triggerElement: ReactNode
libraryItem?: ArticleAttributes
articleActionHandler: (action: string, arg?: unknown) => void
}
@ -14,8 +17,18 @@ export function ReaderDropdownMenu(props: DropdownMenuProps): JSX.Element {
return (
<Dropdown triggerElement={props.triggerElement}>
<DropdownOption
onSelect={() => props.articleActionHandler('archive')}
title="Archive (e)"
onSelect={async () => {
if (props.libraryItem?.state === State.ARCHIVED) {
props.articleActionHandler('unarchive')
} else {
props.articleActionHandler('archive')
}
}}
title={
props.libraryItem?.state === State.ARCHIVED
? 'Unarchive (e)'
: 'Archive (e)'
}
/>
<DropdownOption
onSelect={() => props.articleActionHandler('setLabels')}
@ -26,7 +39,9 @@ export function ReaderDropdownMenu(props: DropdownMenuProps): JSX.Element {
title="Edit info (i)"
/>
<DropdownOption
onSelect={() => props.articleActionHandler('delete')}
onSelect={async () => {
props.articleActionHandler('delete')
}}
title="Remove (#)"
/>
<DropdownSeparator />

View file

@ -11,20 +11,21 @@ import { setupAnalytics } from '../../lib/analytics'
import { primaryCommands } from '../../lib/keyboardShortcuts/navigationShortcuts'
import { logout } from '../../lib/logout'
import { useApplyLocalTheme } from '../../lib/hooks/useApplyLocalTheme'
import { updateTheme } from '../../lib/themeUpdater'
import { Priority, useRegisterActions } from 'kbar'
import { ThemeId, theme } from '../tokens/stitches.config'
import { useRegisterActions } from 'kbar'
import { theme } from '../tokens/stitches.config'
import { NavigationMenu } from './navMenu/NavigationMenu'
import { Button } from '../elements/Button'
import { List } from '@phosphor-icons/react'
import { LIBRARY_LEFT_MENU_WIDTH } from './navMenu/LibraryLegacyMenu'
import { AddLinkModal } from './AddLinkModal'
import { saveUrlMutation } from '../../lib/networking/mutations/saveUrlMutation'
import { v4 as uuidv4 } from 'uuid'
import {
showErrorToast,
showSuccessToastWithAction,
} from '../../lib/toastHelpers'
import useWindowDimensions from '../../lib/hooks/useGetWindowDimensions'
import { useAddItem } from '../../lib/networking/library_items/useLibraryItems'
import { useHandleAddUrl } from '../../lib/hooks/useHandleAddUrl'
export type NavigationSection =
| 'home'
@ -52,6 +53,7 @@ export function NavigationLayout(props: NavigationLayoutProps): JSX.Element {
const [showLogoutConfirmation, setShowLogoutConfirmation] = useState(false)
const [showKeyboardCommandsModal, setShowKeyboardCommandsModal] =
useState(false)
const addItem = useAddItem()
useRegisterActions(navigationCommands(router))
@ -84,22 +86,7 @@ export function NavigationLayout(props: NavigationLayoutProps): JSX.Element {
const [showAddLinkModal, setShowAddLinkModal] = useState(false)
const handleLinkAdded = useCallback(
async (link: string, timezone: string, locale: string) => {
const result = await saveUrlMutation(link, timezone, locale)
if (result) {
showSuccessToastWithAction('Link saved', 'Read now', async () => {
window.location.href = `/article?url=${encodeURIComponent(link)}`
return Promise.resolve()
})
// const id = result.url?.match(/[^/]+$/)?.[0] ?? ''
// performActionOnItem('refresh', undefined as unknown as any)
} else {
showErrorToast('Error saving link', { position: 'bottom-right' })
}
},
[]
)
const handleLinkAdded = useHandleAddUrl()
useEffect(() => {
document.addEventListener('logout', showLogout)

View file

@ -0,0 +1,443 @@
import { useRouter } from 'next/router'
import { NodeApi, SimpleTree, Tree, TreeApi } from 'react-arborist'
import useResizeObserver from 'use-resize-observer'
import {
Shortcut,
useGetShortcuts,
useSetShortcuts,
} from '../../lib/networking/shortcuts/useShortcuts'
import { usePersistedState } from '../../lib/hooks/usePersistedState'
import { CSSProperties, useCallback, useMemo, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { Box, HStack, SpanBox } from '../elements/LayoutPrimitives'
import { Dropdown, DropdownOption } from '../elements/DropdownElements'
import { DotsThree, ListMagnifyingGlass, Tag } from '@phosphor-icons/react'
import { ShortcutFolderClosed } from '../elements/icons/ShortcutFolderClosed'
import { theme } from '../tokens/stitches.config'
import { ShortcutFolderOpen } from '../elements/icons/ShortcutFolderOpen'
import { CoverImage } from '../elements/CoverImage'
import { NewsletterIcon } from '../elements/icons/NewsletterIcon'
import { FollowingIcon } from '../elements/icons/FollowingIcon'
import { StyledText } from '../elements/StyledText'
import { OpenMap } from 'react-arborist/dist/module/state/open-slice'
type ShortcutsTreeProps = {
treeRef: React.MutableRefObject<TreeApi<Shortcut> | undefined>
}
export const ShortcutsTree = (props: ShortcutsTreeProps): JSX.Element => {
const router = useRouter()
const { ref, width, height } = useResizeObserver()
const { data, isLoading } = useGetShortcuts()
const setShorcuts = useSetShortcuts()
const [folderOpenState, setFolderOpenState] = usePersistedState<
Record<string, boolean>
>({
key: 'nav-menu-open-state',
isSessionStorage: false,
initialValue: {},
})
const tree = useMemo(() => {
const result = new SimpleTree<Shortcut>((data ?? []) as Shortcut[])
return result
}, [data])
const syncTreeData = async (data: Shortcut[]) => {
await setShorcuts.mutateAsync({ shortcuts: data })
}
const onMove = useCallback(
async (args: {
dragIds: string[]
parentId: null | string
index: number
}) => {
for (const id of args.dragIds) {
tree?.move({ id, parentId: args.parentId, index: args.index })
}
await syncTreeData(tree.data)
},
[tree, data]
)
const onCreate = useCallback(
async (args: { parentId: string | null; index: number; type: string }) => {
const data = { id: uuidv4(), name: '', type: 'folder' } as any
if (args.type === 'internal') {
data.children = []
}
tree.create({ parentId: args.parentId, index: args.index, data })
await syncTreeData(tree.data)
return data
},
[tree, data]
)
const onDelete = useCallback(
async (args: { ids: string[] }) => {
args.ids.forEach((id) => tree.drop({ id }))
await syncTreeData(tree.data)
},
[tree, data]
)
const onRename = useCallback(
async (args: { name: string; id: string }) => {
tree.update({ id: args.id, changes: { name: args.name } as any })
await syncTreeData(tree.data)
},
[tree, data]
)
const onToggle = useCallback(
(id: string) => {
if (id && props.treeRef.current) {
const isOpen = props.treeRef.current?.isOpen(id)
const newItem: OpenMap = {}
newItem[id] = isOpen
setFolderOpenState({ ...folderOpenState, ...newItem })
}
},
[props, folderOpenState, setFolderOpenState]
)
const onActivate = useCallback(
(node: NodeApi<Shortcut>) => {
if (node.data.type == 'folder') {
const join = node.data.join
if (join == 'or') {
const query = node.children
?.map((child) => {
return `(${child.data.filter})`
})
.join(' OR ')
}
} else if (node.data.section != null && node.data.filter != null) {
router.push(`/${node.data.section}?q=${node.data.filter}`)
}
},
[tree, router]
)
function countTotalShortcuts(shortcuts: Shortcut[]): number {
let total = 0
for (const shortcut of shortcuts) {
// Count the current shortcut
total++
// If the shortcut has children, recursively count them
if (shortcut.children && shortcut.children.length > 0) {
total += countTotalShortcuts(shortcut.children)
}
}
return total
}
const maximumHeight = useMemo(() => {
if (!data) {
return 320
}
return countTotalShortcuts(data as Shortcut[]) * 36
}, [data])
return (
<Box
ref={ref}
css={{
height: maximumHeight,
flexGrow: 1,
minBlockSize: 0,
}}
>
{!isLoading && (
<Tree
ref={props.treeRef}
data={data as Shortcut[]}
onCreate={onCreate}
onMove={onMove}
onDelete={onDelete}
onRename={onRename}
onToggle={onToggle}
onActivate={onActivate}
rowHeight={36}
initialOpenState={folderOpenState}
width={width}
height={maximumHeight}
>
{NodeRenderer}
</Tree>
)}
</Box>
)
}
function NodeRenderer(args: {
style: CSSProperties
node: NodeApi<Shortcut>
tree: TreeApi<Shortcut>
dragHandle?: (el: HTMLDivElement | null) => void
preview?: boolean
}) {
const isSelected = false
const [menuVisible, setMenuVisible] = useState(false)
const [menuOpened, setMenuOpened] = useState(false)
return (
<HStack
ref={args.dragHandle}
alignment="center"
distribution="start"
css={{
pl: `${20 + args.node.level * 15}px`,
mb: '2px',
gap: '10px',
display: 'flex',
width: '100%',
maxWidth: '100%',
height: '34px',
backgroundColor: isSelected ? '$thLibrarySelectionColor' : 'unset',
fontSize: '15px',
fontWeight: 'regular',
fontFamily: '$display',
color: isSelected
? '$thLibraryMenuSecondary'
: '$thLibraryMenuUnselected',
verticalAlign: 'middle',
borderRadius: '3px',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
'&:hover': {
backgroundColor: isSelected
? '$thLibrarySelectionColor'
: '$thBackground4',
},
'&:active': {
outline: 'unset',
backgroundColor: isSelected
? '$thLibrarySelectionColor'
: '$thBackground4',
},
'&:hover [role="hover-menu"]': {
opacity: '1',
},
}}
onMouseEnter={() => {
setMenuVisible(true)
}}
onMouseLeave={() => {
setMenuVisible(false)
}}
title={args.node.data.name}
onClick={(e) => {
// router.push(`/` + props.section)
}}
>
<HStack
css={{
width: '100%',
height: '100%',
}}
distribution="start"
alignment="center"
>
<NodeItemContents node={args.node} />
<SpanBox
role="hover-menu"
css={{
display: 'flex',
ml: 'auto',
mr: '15px',
opacity: menuVisible || menuOpened ? '1' : '0',
}}
>
<Dropdown
side="bottom"
triggerElement={<DotsThree size={20} />}
css={{ ml: 'auto' }}
onOpenChange={(open) => {
setMenuOpened(open)
}}
>
<DropdownOption
onSelect={() => {
args.tree.delete(args.node)
}}
title="Remove"
/>
{/* {args.node.data.type == 'folder' && (
<DropdownOption
onSelect={() => {
args.node.data.join = 'or'
}}
title="Folder query: OR"
/>
)} */}
</Dropdown>
</SpanBox>
</HStack>
</HStack>
)
}
type NodeItemContentsProps = {
node: NodeApi<Shortcut>
}
const NodeItemContents = (props: NodeItemContentsProps): JSX.Element => {
if (props.node.isEditing) {
return (
<input
autoFocus
type="text"
defaultValue={props.node.data.name}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => props.node.reset()}
onKeyDown={(e) => {
if (e.key === 'Escape') {
props.node.reset()
}
if (e.key === 'Enter') {
// props.node.data = {
// id: 'new-folder',
// type: 'folder',
// name: e.currentTarget.value,
// }
props.node.submit(e.currentTarget.value)
props.node.activate()
}
}}
/>
)
}
if (props.node.isLeaf) {
const shortcut = props.node.data
if (shortcut) {
switch (shortcut.type) {
case 'feed':
case 'newsletter':
return (
<SpanBox>
<FeedOrNewsletterShortcut shortcut={shortcut} />
</SpanBox>
)
case 'label':
return (
<Box>
<LabelShortcut shortcut={shortcut} />
</Box>
)
case 'search':
return (
<Box>
<SearchShortcut shortcut={shortcut} />
</Box>
)
}
}
} else {
return (
<HStack
distribution="start"
alignment="center"
css={{ gap: '10px', width: '100%' }}
onClick={(event) => {
props.node.toggle()
event.preventDefault()
}}
>
{props.node.isClosed ? (
<ShortcutFolderClosed
color={theme.colors.thLibraryMenuPrimary.toString()}
/>
) : (
<ShortcutFolderOpen
color={theme.colors.thLibraryMenuPrimary.toString()}
/>
)}
{props.node.data.name}
</HStack>
)
}
return <></>
}
type ShortcutItemProps = {
shortcut: Shortcut
}
const FeedOrNewsletterShortcut = (props: ShortcutItemProps): JSX.Element => {
return (
<HStack
alignment="center"
distribution="start"
css={{ pl: '10px', width: '100%', gap: '10px' }}
key={`search-${props.shortcut.id}`}
>
<HStack
distribution="start"
alignment="center"
css={{ minWidth: '20px' }}
>
{props.shortcut.icon ? (
<CoverImage
src={props.shortcut.icon}
width={20}
height={20}
css={{ borderRadius: '20px' }}
/>
) : props.shortcut.type == 'newsletter' ? (
<NewsletterIcon color="#F59932" size={18} />
) : (
<FollowingIcon color="#F59932" size={21} />
)}
</HStack>
<StyledText style="settingsItem">{props.shortcut.name}</StyledText>
</HStack>
)
}
const SearchShortcut = (props: ShortcutItemProps): JSX.Element => {
return (
<HStack
alignment="center"
distribution="start"
css={{ pl: '10px', width: '100%', gap: '7px' }}
key={`search-${props.shortcut.id}`}
>
<HStack
distribution="start"
alignment="center"
css={{ minWidth: '20px' }}
>
<ListMagnifyingGlass size={17} />
</HStack>
<StyledText style="settingsItem">{props.shortcut.name}</StyledText>
</HStack>
)
}
const LabelShortcut = (props: ShortcutItemProps): JSX.Element => {
return (
<HStack
alignment="center"
distribution="start"
css={{ width: '100%', gap: '7px' }}
key={`search-${props.shortcut.id}`}
>
<Tag
size={15}
color={props.shortcut.label?.color ?? 'gray'}
weight="fill"
/>
<StyledText style="settingsItem" css={{ pb: '1px' }}>
{props.shortcut.name}
</StyledText>
</HStack>
)
}

View file

@ -8,13 +8,15 @@ import {
ModalTitleBar,
} from '../../elements/ModalPrimitives'
import { SetLabelsControl } from './SetLabelsControl'
import { createLabelMutation } from '../../../lib/networking/mutations/createLabelMutation'
import { showSuccessToast } from '../../../lib/toastHelpers'
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
import { v4 as uuidv4 } from 'uuid'
import { randomLabelColorHex } from '../../../utils/settings-page/labels/labelColorObjects'
import { LabelAction } from '../../../lib/hooks/useSetPageLabels'
import { Button } from '../../elements/Button'
import {
useCreateLabel,
useGetLabels,
} from '../../../lib/networking/labels/useLabels'
type AddBulkLabelsModalProps = {
onOpenChange: (open: boolean) => void
@ -24,12 +26,14 @@ type AddBulkLabelsModalProps = {
export function AddBulkLabelsModal(
props: AddBulkLabelsModalProps
): JSX.Element {
const availableLabels = useGetLabelsQuery()
const { data: availableLabels } = useGetLabels()
const createLabel = useCreateLabel()
const [tabCount, setTabCount] = useState(-1)
const [inputValue, setInputValue] = useState('')
const [tabStartValue, setTabStartValue] = useState('')
const [errorMessage, setErrorMessage] =
useState<string | undefined>(undefined)
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined
)
const errorTimeoutRef = useRef<NodeJS.Timeout | undefined>()
const [highlightLastLabel, setHighlightLastLabel] = useState(false)
const [isSaving, setIsSaving] = useState(false)
@ -97,10 +101,11 @@ export function AddBulkLabelsModal(
(newLabels: Label[], tempLabel: Label) => {
;(async () => {
const currentLabels = newLabels
const newLabel = await createLabelMutation(
tempLabel.name,
tempLabel.color
)
const newLabel = await createLabel.mutateAsync({
name: tempLabel.name,
color: tempLabel.color,
description: undefined,
})
const idx = currentLabels.findIndex((l) => l.id === tempLabel.id)
if (newLabel) {
showSuccessToast(`Created label ${newLabel.name}`, {
@ -132,7 +137,7 @@ export function AddBulkLabelsModal(
const trimmedValue = value.trim()
const current = selectedLabels.labels ?? []
const lowerCasedValue = trimmedValue.toLowerCase()
const existing = availableLabels.labels.find(
const existing = availableLabels?.find(
(l) => l.name.toLowerCase() == lowerCasedValue
)

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'
@ -11,6 +11,7 @@ import { TrashIcon } from '../../elements/icons/TrashIcon'
import { LabelIcon } from '../../elements/icons/LabelIcon'
import { EditInfoIcon } from '../../elements/icons/EditInfoIcon'
import { UnarchiveIcon } from '../../elements/icons/UnarchiveIcon'
import { State } from '../../../lib/networking/fragments/articleFragment'
export type ArticleActionsMenuLayout = 'top' | 'side'
@ -163,7 +164,7 @@ export function ArticleActionsMenu(
<TrashIcon size={24} color={theme.colors.thHighContrast.toString()} />
</Button>
{!props.article?.isArchived ? (
{props.article?.state !== State.ARCHIVED ? (
<Button
title="Archive (e)"
style="articleActionIcon"

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,14 @@ 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/queries/useGetLibraryItemsQuery'
import {
ArticleAttributes,
Recommendation,
TextDirection,
useUpdateItemReadStatus,
} 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
@ -117,23 +116,28 @@ const RecommendationComments = (
export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
const [labels, setLabels] = useState(props.labels)
const [title, setTitle] = useState(props.article.title)
const [title, setTitle] = useState<string | undefined>(undefined)
const [showReportIssuesModal, setShowReportIssuesModal] = useState(false)
const [fontSize, setFontSize] = useState(props.fontSize ?? 20)
const [highlightOnRelease, setHighlightOnRelease] = useState(
props.highlightOnRelease
)
// iOS app embed can overide the original margin and line height
const [maxWidthPercentageOverride, setMaxWidthPercentageOverride] =
useState<number | null>(null)
const [lineHeightOverride, setLineHeightOverride] =
useState<number | null>(null)
const [fontFamilyOverride, setFontFamilyOverride] =
useState<string | null>(null)
const [highContrastTextOverride, setHighContrastTextOverride] =
useState<boolean | undefined>(undefined)
const [justifyTextOverride, setJustifyTextOverride] =
useState<boolean | undefined>(undefined)
const [maxWidthPercentageOverride, setMaxWidthPercentageOverride] = useState<
number | null
>(null)
const [lineHeightOverride, setLineHeightOverride] = useState<number | null>(
null
)
const [fontFamilyOverride, setFontFamilyOverride] = useState<string | null>(
null
)
const [highContrastTextOverride, setHighContrastTextOverride] = useState<
boolean | undefined
>(undefined)
const [justifyTextOverride, setJustifyTextOverride] = useState<
boolean | undefined
>(undefined)
const highlightHref = useRef(
window.location.hash ? window.location.hash.split('#')[1] : null
)
@ -444,9 +448,9 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
'-webkit-line-clamp': '6',
},
}}
title={title}
title={title ?? props.article.title}
>
{title}
{title ?? props.article.title}
</StyledText>
<ArticleSubtitle
author={props.article.author}
@ -520,9 +524,6 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
item={props.article}
scrollToHighlight={highlightHref}
highlights={props.article.highlights}
articleTitle={title}
articleAuthor={props.article.author ?? ''}
articleId={props.article.id}
isAppleAppEmbed={props.isAppleAppEmbed}
highlightBarDisabled={props.highlightBarDisabled}
showHighlightsModal={props.showHighlightsModal}

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'
@ -11,10 +11,6 @@ import {
import PSPDFKit from 'pspdfkit'
import { Instance, HighlightAnnotation, List, Annotation, Rect } from 'pspdfkit'
import type { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation'
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation'
import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeHighlightMutation'
import { useCanShareNative } from '../../../lib/hooks/useCanShareNative'
import { pspdfKitKey } from '../../../lib/appConfig'
import { NotebookModal } from './NotebookModal'
@ -42,13 +38,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(() => {
@ -309,56 +307,6 @@ export default function EpubContainer(props: EpubContainerProps): JSX.Element {
{/* EPUB CONTAINER
<div ></div> */}
</Box>
{noteTarget && (
<HighlightNoteModal
highlight={noteTarget}
libraryItemId={props.article.id}
author={props.article.author ?? ''}
title={props.article.title}
onUpdate={(highlight: Highlight) => {
const savedHighlight = highlightsRef.current.find(
(other: Highlight) => {
return other.id == highlight.id
}
)
if (savedHighlight) {
savedHighlight.annotation = highlight.annotation
}
}}
onOpenChange={() => {
setNoteTarget(undefined)
}}
/>
)}
{props.showHighlightsModal && (
<NotebookModal
key={notebookKey}
viewer={props.viewer}
item={props.article}
onClose={(updatedHighlights, deletedAnnotations) => {
console.log(
'closed PDF notebook: ',
updatedHighlights,
deletedAnnotations
)
deletedAnnotations.forEach((highlight) => {
const event = new CustomEvent('deleteHighlightbyId', {
detail: highlight.id,
})
document.dispatchEvent(event)
})
props.setShowHighlightsModal(false)
}}
viewHighlightInReader={(highlightId) => {
const event = new CustomEvent('scrollToHighlightId', {
detail: highlightId,
})
document.dispatchEvent(event)
props.setShowHighlightsModal(false)
}}
/>
)}
</Box>
)
}

View file

@ -9,14 +9,13 @@ 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
title: string
highlight?: Highlight
libraryItemId: string
libraryItemSlug: string
onUpdate: (updatedHighlight: Highlight) => void
onOpenChange: (open: boolean) => void
createHighlightForNote?: (note?: string) => Promise<Highlight | undefined>
@ -25,6 +24,7 @@ type HighlightNoteModalProps = {
export function HighlightNoteModal(
props: HighlightNoteModalProps
): JSX.Element {
const updateHighlight = useUpdateHighlight()
const [noteContent, setNoteContent] = useState(
props.highlight?.annotation ?? ''
)
@ -38,20 +38,25 @@ 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,
slug: props.libraryItemSlug,
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

@ -1,6 +1,6 @@
import { useState } from 'react'
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { ReadableItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { ReadableItem } from '../../../lib/networking/library_items/useLibraryItems'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { HighlightView } from '../../patterns/HighlightView'

View file

@ -24,7 +24,7 @@ import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { ArticleMutations } from '../../../lib/articleActions'
import { isTouchScreenDevice } from '../../../lib/deviceType'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { ReadableItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { ReadableItem } from '../../../lib/networking/library_items/useLibraryItems'
import { SetHighlightLabelsModalPresenter } from './SetLabelsModalPresenter'
import 'react-sliding-pane/dist/react-sliding-pane.css'
import { NotebookContent } from './Notebook'
@ -39,9 +39,6 @@ type HighlightsLayerProps = {
item: ReadableItem
highlights: Highlight[]
articleId: string
articleTitle: string
articleAuthor: string
isAppleAppEmbed: boolean
highlightBarDisabled: boolean
showHighlightsModal: boolean
@ -105,7 +102,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
const result = await createHighlight(
{
selection: selection,
articleId: props.articleId,
articleId: props.item.id,
existingHighlights: highlights,
color: options?.color,
highlightStartEndOffsets: highlightLocations,
@ -141,7 +138,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
[
highlightLocations,
highlights,
props.articleId,
props.item.id,
props.articleMutations,
setSelectionData,
]
@ -189,7 +186,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
const didDeleteHighlight =
await props.articleMutations.deleteHighlightMutation(
props.articleId,
props.item.id,
highlightId
)
@ -226,7 +223,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
updateHighlightsCallback(highlight)
;(async () => {
const update = await props.articleMutations.updateHighlightMutation({
libraryItemId: props.articleId,
libraryItemId: props.item.id,
highlightId: highlight.id,
color: color,
})
@ -718,7 +715,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
const annotation = event.annotation ?? ''
const result = await props.articleMutations.updateHighlightMutation({
libraryItemId: props.articleId,
libraryItemId: props.item.id,
highlightId: focusedHighlight.id,
annotation: event.annotation ?? '',
})
@ -800,9 +797,8 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
{highlightModalAction?.highlightModalAction == 'addComment' && (
<HighlightNoteModal
highlight={highlightModalAction.highlight}
author={props.articleAuthor}
title={props.articleTitle}
libraryItemId={props.articleId}
libraryItemId={props.item.id}
libraryItemSlug={props.item.slug}
onUpdate={updateHighlightsCallback}
onOpenChange={() =>
setHighlightModalAction({ highlightModalAction: 'none' })

View file

@ -5,21 +5,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import 'react-markdown-editor-lite/lib/index.css'
import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation'
import { v4 as uuidv4 } from 'uuid'
import { nanoid } from 'nanoid'
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
import { HighlightViewItem } from './HighlightViewItem'
import { ConfirmationModal } from '../../patterns/ConfirmationModal'
import { TrashIcon } from '../../elements/icons/TrashIcon'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { ReadableItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
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'
import {
useCreateHighlight,
useDeleteHighlight,
useUpdateHighlight,
} from '../../../lib/networking/highlights/useItemHighlights'
type NotebookContentProps = {
viewer: UserBasicData
@ -42,12 +45,14 @@ type NoteState = {
export function NotebookContent(props: NotebookContentProps): JSX.Element {
const isDark = isDarkTheme()
const createHighlight = useCreateHighlight()
const deleteHighlight = useDeleteHighlight()
const updateHighlight = useUpdateHighlight()
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)
@ -88,12 +93,16 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
noteState.current.createStarted = new Date()
;(async () => {
try {
const success = await createHighlightMutation({
id: newNoteId,
shortId: nanoid(8),
type: 'NOTE',
articleId: props.item.id,
annotation: text,
const success = await createHighlight.mutateAsync({
itemId: props.item.id,
slug: props.item.slug,
input: {
id: newNoteId,
shortId: nanoid(8),
type: 'NOTE',
articleId: props.item.id,
annotation: text,
},
})
if (success) {
noteState.current.note = success
@ -112,7 +121,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 +131,7 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
setNoteText('')
}
return result
}, [articleData])
}, [article])
useEffect(() => {
if (highlights && props.onAnnotationsChanged) {
@ -165,7 +174,11 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
highlights
?.filter((h) => h.type === 'NOTE')
.forEach(async (h) => {
const result = await deleteHighlightMutation(props.item.id, h.id)
const result = await deleteHighlight.mutateAsync({
itemId: props.item.id,
slug: props.item.slug,
highlightId: h.id,
})
if (!result) {
showErrorToast('Error deleting note')
}
@ -179,16 +192,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}
@ -257,7 +260,8 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
setSetLabelsTarget={setLabelsTarget}
setShowConfirmDeleteHighlightId={setShowConfirmDeleteHighlightId}
updateHighlight={() => {
mutate()
// nothing should be needed here anymore with new caching
console.log('update highlight')
}}
/>
))}
@ -294,11 +298,11 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
onAccept={() => {
;(async () => {
const highlightId = showConfirmDeleteHighlightId
const success = await deleteHighlightMutation(
props.item.id,
showConfirmDeleteHighlightId
)
mutate()
const success = await deleteHighlight.mutateAsync({
itemId: props.item.id,
slug: props.item.slug,
highlightId: showConfirmDeleteHighlightId,
})
if (success) {
showSuccessToast('Highlight deleted.', {
position: 'bottom-right',
@ -333,7 +337,6 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
console.log('update highlight: ', highlight)
}}
onOpenChange={() => {
mutate()
setLabelsTarget(undefined)
}}
/>

View file

@ -6,7 +6,7 @@ import { Button } from '../../elements/Button'
import { ExportIcon } from '../../elements/icons/ExportIcon'
import { useCallback } from 'react'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { ReadableItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { ReadableItem } from '../../../lib/networking/library_items/useLibraryItems'
import { useGetArticleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
import { highlightsAsMarkdown } from '../homeFeed/HighlightItem'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'

View file

@ -17,7 +17,7 @@ import { highlightsAsMarkdown } from '../homeFeed/HighlightItem'
import 'react-markdown-editor-lite/lib/index.css'
import { NotebookContent } from './Notebook'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { ReadableItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { ReadableItem } from '../../../lib/networking/library_items/useLibraryItems'
type NotebookModalProps = {
viewer: UserBasicData

View file

@ -1,4 +1,4 @@
import { ReadableItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { ReadableItem } from '../../../lib/networking/library_items/useLibraryItems'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import 'react-sliding-pane/dist/react-sliding-pane.css'
import { NotebookContent } from './Notebook'

View file

@ -1,4 +1,7 @@
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import {
ArticleAttributes,
useUpdateItemReadStatus,
} from '../../../lib/networking/library_items/useLibraryItems'
import { Box } from '../../elements/LayoutPrimitives'
import { v4 as uuidv4 } from 'uuid'
import { nanoid } from 'nanoid'
@ -7,10 +10,6 @@ import { isDarkTheme } from '../../../lib/themeUpdater'
import PSPDFKit from 'pspdfkit'
import { Instance, HighlightAnnotation, List, Annotation, Rect } from 'pspdfkit'
import type { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation'
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation'
import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeHighlightMutation'
import { pspdfKitKey } from '../../../lib/appConfig'
import { HighlightNoteModal } from './HighlightNoteModal'
import { showErrorToast } from '../../../lib/toastHelpers'
@ -22,6 +21,12 @@ import { NotebookHeader } from './NotebookHeader'
import useWindowDimensions from '../../../lib/hooks/useGetWindowDimensions'
import { ResizableSidebar } from './ResizableSidebar'
import { DEFAULT_HOME_PATH } from '../../../lib/navigations'
import {
useCreateHighlight,
useDeleteHighlight,
useMergeHighlight,
useUpdateHighlight,
} from '../../../lib/networking/highlights/useItemHighlights'
export type PdfArticleContainerProps = {
viewer: UserBasicData
@ -36,9 +41,15 @@ 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 createHighlight = useCreateHighlight()
const deleteHighlight = useDeleteHighlight()
const mergeHighlight = useMergeHighlight()
const updateHighlight = useUpdateHighlight()
const updateItemReadStatus = useUpdateItemReadStatus()
const annotationOmnivoreId = (annotation: Annotation): string | undefined => {
if (
@ -113,7 +124,11 @@ export default function PdfArticleContainer(
.delete(annotation)
.then(() => {
if (annotationId) {
return deleteHighlightMutation(props.article.id, annotationId)
return deleteHighlight.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
highlightId: annotationId,
})
}
})
.then(() => {
@ -214,8 +229,6 @@ export default function PdfArticleContainer(
}),
}
console.log('instnace config: ', config)
instance = await PSPDFKit.load(config)
console.log('created PDF instance', instance)
@ -229,7 +242,11 @@ export default function PdfArticleContainer(
}
const annotationId = annotationOmnivoreId(annotation)
if (annotationId) {
await deleteHighlightMutation(props.article.id, annotationId)
await deleteHighlight.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
highlightId: annotationId,
})
}
})
@ -339,16 +356,21 @@ export default function PdfArticleContainer(
if (overlapping.size === 0) {
const positionPercent = positionPercentForAnnotation(annotation)
const result = await createHighlightMutation({
id: id,
shortId: shortId,
quote: quote,
articleId: props.article.id,
prefix: surroundingText.prefix,
suffix: surroundingText.suffix,
patch: JSON.stringify(serialized),
highlightPositionPercent: positionPercent * 100,
highlightPositionAnchorIndex: annotation.pageIndex,
const result = await createHighlight.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
input: {
id: id,
shortId: shortId,
quote: quote,
articleId: props.article.id,
prefix: surroundingText.prefix,
suffix: surroundingText.suffix,
patch: JSON.stringify(serialized),
highlightPositionPercent: positionPercent * 100,
highlightPositionAnchorIndex: annotation.pageIndex,
},
})
if (result) {
highlightsRef.current.push(result)
@ -384,20 +406,24 @@ export default function PdfArticleContainer(
(ha) => (ha.customData?.omnivoreHighlight as Highlight).id
)
const positionPercent = positionPercentForAnnotation(annotation)
const result = await mergeHighlightMutation({
quote,
id,
shortId,
patch: JSON.stringify(serialized),
prefix: surroundingText.prefix,
suffix: surroundingText.suffix,
articleId: props.article.id,
overlapHighlightIdList: mergedIds.toArray(),
highlightPositionPercent: positionPercent * 100,
highlightPositionAnchorIndex: annotation.pageIndex,
const result = await mergeHighlight.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
input: {
quote,
id,
shortId,
patch: JSON.stringify(serialized),
prefix: surroundingText.prefix,
suffix: surroundingText.suffix,
articleId: props.article.id,
overlapHighlightIdList: mergedIds.toArray(),
highlightPositionPercent: positionPercent * 100,
highlightPositionAnchorIndex: annotation.pageIndex,
},
})
if (result) {
highlightsRef.current.push(result)
if (result && result.highlight) {
highlightsRef.current.push(result.highlight)
}
}
}
@ -410,11 +436,15 @@ export default function PdfArticleContainer(
100,
Math.max(0, ((pageIndex + 1) / instance.totalPageCount) * 100)
)
await articleReadingProgressMutation({
id: props.article.id,
force: true,
readingProgressPercent: percent,
readingProgressAnchorIndex: pageIndex,
await updateItemReadStatus.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
input: {
id: props.article.id,
force: true,
readingProgressPercent: percent,
readingProgressAnchorIndex: pageIndex,
},
})
}
)
@ -517,7 +547,11 @@ export default function PdfArticleContainer(
const storedId = annotationOmnivoreId(annotation)
if (storedId == annotationId) {
await instance.delete(annotation)
await deleteHighlightMutation(props.article.id, annotationId)
await deleteHighlight.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
highlightId: annotationId,
})
const highlightIdx = highlightsRef.current.findIndex((value) => {
return value.id == annotationId
@ -582,8 +616,7 @@ export default function PdfArticleContainer(
<HighlightNoteModal
highlight={noteTarget}
libraryItemId={props.article.id}
author={props.article.author ?? ''}
title={props.article.title}
libraryItemSlug={props.article.slug}
onUpdate={(highlight: Highlight) => {
const savedHighlight = highlightsRef.current.find(
(other: Highlight) => {

View file

@ -4,14 +4,16 @@ import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { styled, theme } from '../../tokens/stitches.config'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
import { Check, Circle, Plus, WarningCircle } from '@phosphor-icons/react'
import { createLabelMutation } from '../../../lib/networking/mutations/createLabelMutation'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { randomLabelColorHex } from '../../../utils/settings-page/labels/labelColorObjects'
import { useRouter } from 'next/router'
import { LabelsPicker } from '../../elements/LabelsPicker'
import { LabelsDispatcher } from '../../../lib/hooks/useSetPageLabels'
import {
useCreateLabel,
useGetLabels,
} from '../../../lib/networking/labels/useLabels'
export interface LabelsProvider {
labels?: Label[]
@ -282,10 +284,10 @@ function Footer(props: FooterProps): JSX.Element {
}
export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
const router = useRouter()
const { inputValue, setInputValue, selectedLabels, setHighlightLastLabel } =
props
const { labels, revalidate } = useGetLabelsQuery()
const { data: labels } = useGetLabels()
const createLabel = useCreateLabel()
// Move focus through the labels list on tab or arrow up/down keys
const [focusedIndex, setFocusedIndex] = useState<number | undefined>(0)
@ -321,9 +323,8 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
props.dispatchLabels({ type: 'SAVE', labels: newSelectedLabels })
props.clearInputState()
revalidate()
},
[isSelected, props, revalidate]
[isSelected, props]
)
const filteredLabels = useMemo(() => {
@ -342,11 +343,11 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
const createLabelFromFilterText = useCallback(
async (text: string) => {
const trimmedLabelName = text.trim()
const label = await createLabelMutation(
trimmedLabelName,
randomLabelColorHex(),
''
)
const label = await createLabel.mutateAsync({
name: trimmedLabelName,
color: randomLabelColorHex(),
description: undefined,
})
if (label) {
showSuccessToast(`Created label ${label.name}`, {
position: 'bottom-right',
@ -425,7 +426,7 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
}, [inputValue, setInputValue, createLabelFromFilterText])
const selectEnteredLabel = useCallback(() => {
const label = labels.find(
const label = labels?.find(
(l: Label) => l.name.toLowerCase() == inputValue.toLowerCase()
)
if (!label) {
@ -509,7 +510,7 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
<Footer
filterText={inputValue}
selectedLabels={props.selectedLabels}
availableLabels={labels}
availableLabels={labels ?? []}
focused={focusedIndex === filteredLabels.length + 1}
createEnteredLabel={createEnteredLabel}
selectEnteredLabel={selectEnteredLabel}

View file

@ -8,13 +8,15 @@ import {
ModalTitleBar,
} from '../../elements/ModalPrimitives'
import { LabelsProvider, SetLabelsControl } from './SetLabelsControl'
import { createLabelMutation } from '../../../lib/networking/mutations/createLabelMutation'
import { showSuccessToast } from '../../../lib/toastHelpers'
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
import { v4 as uuidv4 } from 'uuid'
import { randomLabelColorHex } from '../../../utils/settings-page/labels/labelColorObjects'
import { LabelsDispatcher } from '../../../lib/hooks/useSetPageLabels'
import * as Dialog from '@radix-ui/react-dialog'
import {
useCreateLabel,
useGetLabels,
} from '../../../lib/networking/labels/useLabels'
type SetLabelsModalProps = {
provider: LabelsProvider
@ -28,7 +30,7 @@ type SetLabelsModalProps = {
export function SetLabelsModal(props: SetLabelsModalProps): JSX.Element {
const [inputValue, setInputValue] = useState('')
const { selectedLabels, dispatchLabels } = props
const availableLabels = useGetLabelsQuery()
const { data: availableLabels } = useGetLabels()
const [tabCount, setTabCount] = useState(-1)
const [tabStartValue, setTabStartValue] = useState('')
const [errorMessage, setErrorMessage] = useState<string | undefined>(
@ -37,6 +39,8 @@ export function SetLabelsModal(props: SetLabelsModalProps): JSX.Element {
const errorTimeoutRef = useRef<NodeJS.Timeout | undefined>()
const [highlightLastLabel, setHighlightLastLabel] = useState(false)
const createLabel = useCreateLabel()
const showMessage = useCallback(
(msg: string, timeout?: number) => {
if (errorTimeoutRef.current) {
@ -82,10 +86,11 @@ export function SetLabelsModal(props: SetLabelsModalProps): JSX.Element {
(newLabels: Label[], tempLabel: Label) => {
;(async () => {
const currentLabels = newLabels
const newLabel = await createLabelMutation(
tempLabel.name,
tempLabel.color
)
const newLabel = await createLabel.mutateAsync({
name: tempLabel.name,
color: tempLabel.color,
description: undefined,
})
const idx = currentLabels.findIndex((l) => l.id === tempLabel.id)
if (newLabel) {
showSuccessToast(`Created label ${newLabel.name}`, {
@ -116,7 +121,7 @@ export function SetLabelsModal(props: SetLabelsModalProps): JSX.Element {
(value: string) => {
const current = selectedLabels ?? []
const lowerCasedValue = value.toLowerCase()
const existing = availableLabels.labels.find(
const existing = availableLabels?.find(
(l) => l.name.toLowerCase() == lowerCasedValue
)

View file

@ -4,21 +4,24 @@ import { LabelsProvider } from './SetLabelsControl'
import { SetLabelsModal } from './SetLabelsModal'
import { useSetHighlightLabels } from '../../../lib/hooks/useSetHighlightLabels'
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { LibraryItemNode } from '../../../lib/networking/library_items/useLibraryItems'
type SetPageLabelsModalPresenterProps = {
articleId: string
article: LabelsProvider
libraryItem: LibraryItemNode
onOpenChange: (open: boolean) => void
}
export function SetPageLabelsModalPresenter(
props: SetPageLabelsModalPresenterProps
): JSX.Element {
const [labels, dispatchLabels] = useSetPageLabels(props.articleId)
const [labels, dispatchLabels] = useSetPageLabels(
props.libraryItem.id,
props.libraryItem.slug
)
const onOpenChange = useCallback(() => {
if (props.article) {
props.article.labels = labels.labels
if (props.libraryItem) {
props.libraryItem.labels = labels.labels
}
props.onOpenChange(true)
}, [props, labels])
@ -26,13 +29,13 @@ export function SetPageLabelsModalPresenter(
useEffect(() => {
dispatchLabels({
type: 'RESET',
labels: props.article.labels ?? [],
labels: props.libraryItem.labels ?? [],
})
}, [props.article, dispatchLabels])
}, [props.libraryItem, dispatchLabels])
return (
<SetLabelsModal
provider={props.article}
provider={props.libraryItem}
selectedLabels={labels.labels}
dispatchLabels={dispatchLabels}
onOpenChange={onOpenChange}

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'
@ -11,6 +11,7 @@ import { EditInfoIcon } from '../../elements/icons/EditInfoIcon'
import { ReaderSettingsIcon } from '../../elements/icons/ReaderSettingsIcon'
import { CircleUtilityMenuIcon } from '../../elements/icons/CircleUtilityMenuIcon'
import { UnarchiveIcon } from '../../elements/icons/UnarchiveIcon'
import { State } from '../../../lib/networking/fragments/articleFragment'
export type ArticleActionsMenuLayout = 'top' | 'side'
@ -94,15 +95,12 @@ export function VerticalArticleActionsMenu(
css={{
display: 'flex',
alignItems: 'center',
'@mdDown': {
display: 'none',
},
}}
>
<TrashIcon size={24} color={theme.colors.thHighContrast.toString()} />
</Button>
{!props.article?.isArchived ? (
{props.article?.state !== State.ARCHIVED ? (
<Button
title="Archive (e)"
style="articleActionIcon"
@ -155,6 +153,7 @@ export function VerticalArticleActionsMenu(
</Button>
<ReaderDropdownMenu
libraryItem={props.article}
triggerElement={
<CircleUtilityMenuIcon
size={24}

View file

@ -1,8 +1,10 @@
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 { LibraryItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import {
ArticleAttributes,
useUpdateItem,
} 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'
import { FormInput } from '../../elements/FormElements'
@ -24,23 +26,28 @@ type EditLibraryItemModalProps = {
export function EditLibraryItemModal(
props: EditLibraryItemModalProps
): JSX.Element {
const updateItem = useUpdateItem()
const onSave = useCallback(
(
title: string,
author: string | undefined,
description: string,
description: string | undefined,
savedAt: Dayjs,
publishedAt: Dayjs | undefined
) => {
;(async () => {
if (title !== '') {
const res = await updatePageMutation({
pageId: props.item.node.id,
title,
description,
byline: author,
savedAt: savedAt.toISOString(),
publishedAt: publishedAt ? publishedAt.toISOString() : undefined,
const res = await updateItem.mutateAsync({
itemId: props.item.node.id,
slug: props.item.node.slug,
input: {
pageId: props.item.node.id,
title,
description,
byline: author,
savedAt: savedAt.toISOString(),
publishedAt: publishedAt ? publishedAt.toISOString() : undefined,
},
})
if (res) {
@ -95,30 +102,35 @@ type EditArticleModalProps = {
updateArticle: (
title: string,
author: string | undefined,
description: string,
description: string | undefined,
savedAt: string,
publishedAt: string | undefined
) => void
}
export function EditArticleModal(props: EditArticleModalProps): JSX.Element {
const updateItem = useUpdateItem()
const onSave = useCallback(
(
title: string,
author: string | undefined,
description: string,
description: string | undefined,
savedAt: Dayjs,
publishedAt: Dayjs | undefined
) => {
;(async () => {
if (title !== '') {
const res = await updatePageMutation({
pageId: props.article.id,
title,
description,
byline: author,
savedAt: savedAt.toISOString(),
publishedAt: publishedAt ? publishedAt.toISOString() : undefined,
const res = await updateItem.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
input: {
pageId: props.article.id,
title,
description,
byline: author,
savedAt: savedAt.toISOString(),
publishedAt: publishedAt ? publishedAt.toISOString() : undefined,
},
})
if (res) {
props.updateArticle(
@ -165,7 +177,7 @@ export function EditArticleModal(props: EditArticleModalProps): JSX.Element {
type EditItemModalProps = {
title: string
author: string | undefined
description: string
description: string | undefined
savedAt: Dayjs
publishedAt: Dayjs | undefined
@ -174,7 +186,7 @@ type EditItemModalProps = {
onSave: (
title: string,
author: string | undefined,
description: string,
description: string | undefined,
savedAt: Dayjs,
publishedAt: Dayjs | undefined
) => void

View file

@ -2,7 +2,7 @@ import Link from 'next/link'
import { DotsThreeVertical } from '@phosphor-icons/react'
import { useCallback } from 'react'
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { ReadableItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { ReadableItem } from '../../../lib/networking/library_items/useLibraryItems'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import {

View file

@ -6,7 +6,7 @@ import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import {
LibraryItem,
LibraryItemNode,
} from '../../../lib/networking/queries/useGetLibraryItemsQuery'
} from '../../../lib/networking/library_items/useLibraryItems'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'

View file

@ -10,7 +10,7 @@ import { LayoutType, LibraryMode } from './HomeFeedContainer'
import { OmnivoreSmallLogo } from '../../elements/images/OmnivoreNameLogo'
import { DEFAULT_HEADER_HEIGHT, HeaderSpacer } from './HeaderSpacer'
import { LIBRARY_LEFT_MENU_WIDTH } from '../navMenu/LibraryMenu'
import { BulkAction } from '../../../lib/networking/mutations/bulkActionMutation'
import { BulkAction } from '../../../lib/networking/library_items/useLibraryItems'
import { HeaderToggleGridIcon } from '../../elements/icons/HeaderToggleGridIcon'
import { HeaderToggleListIcon } from '../../elements/icons/HeaderToggleListIcon'
import { HeaderToggleTLDRIcon } from '../../elements/icons/HeaderToggleTLDRIcon'
@ -123,7 +123,7 @@ function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element {
>
{props.multiSelectMode !== 'off' ? (
<>
<MultiSelectControls {...props} />
<MultiSelectControls {...props} folder={'library'} />
</>
) : (
<HeaderControls {...props} />
@ -310,7 +310,7 @@ export function SearchBox(props: SearchBoxProps): JSX.Element {
},
}}
>
<CheckBoxButton {...props} />
<CheckBoxButton {...props} folder={'library'} />
</HStack>
<HStack
alignment="center"

View file

@ -2,7 +2,7 @@ import { useState } from 'react'
import { theme } from '../../tokens/stitches.config'
import { Box, HStack, SpanBox } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { BulkAction } from '../../../lib/networking/mutations/bulkActionMutation'
import { BulkAction } from '../../../lib/networking/library_items/useLibraryItems'
import { ArchiveIcon } from '../../elements/icons/ArchiveIcon'
import { LabelIcon } from '../../elements/icons/LabelIcon'
import { TrashIcon } from '../../elements/icons/TrashIcon'
@ -14,10 +14,13 @@ import { HeaderCheckboxIcon } from '../../elements/icons/HeaderCheckboxIcon'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { MarkAsReadIcon } from '../../elements/icons/MarkAsReadIcon'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { UnarchiveIcon } from '../../elements/icons/UnarchiveIcon'
import { MoveToInboxIcon } from '../../elements/icons/MoveToInboxIcon'
export type MultiSelectProps = {
viewer: UserBasicData | undefined
folder: string | undefined
searchTerm: string | undefined
applySearchQuery: (searchQuery: string) => void
@ -116,7 +119,7 @@ export const MultiSelectControls = (props: MultiSelectProps): JSX.Element => {
<SpanBox
css={{
display: 'none',
fontSize: '14px',
fontSize: '11px',
fontFamily: '$display',
marginRight: 'auto',
'@mdDown': {
@ -126,9 +129,14 @@ export const MultiSelectControls = (props: MultiSelectProps): JSX.Element => {
>
{props.numItemsSelected} items
</SpanBox>
<ArchiveButton {...props} />
{props.folder !== 'archive' && <ArchiveButton {...props} />}
<AddLabelsButton setShowLabelsModal={setShowLabelsModal} />
<RemoveItemsButton setShowConfirmDelete={setShowConfirmDelete} />
{props.folder == 'subscriptions' && (
<MoveToLibraryButton {...props} />
)}
{props.folder !== 'trash' && (
<RemoveItemsButton setShowConfirmDelete={setShowConfirmDelete} />
)}
<MarkAsReadButton {...props} />
{showConfirmDelete && (
<ConfirmationModal
@ -255,6 +263,41 @@ export const MarkAsReadButton = (props: MultiSelectProps): JSX.Element => {
)
}
export const MoveToLibraryButton = (props: MultiSelectProps): JSX.Element => {
const [color, setColor] = useState<string>(
theme.colors.thTextContrast2.toString()
)
return (
<Button
title="Move to library"
css={{
p: '5px',
display: 'flex',
'&:hover': {
bg: '$ctaBlue',
borderRadius: '100px',
opacity: 1.0,
},
}}
onMouseEnter={(event) => {
setColor('white')
event.preventDefault()
}}
onMouseLeave={(event) => {
setColor(theme.colors.thTextContrast2.toString())
event.preventDefault()
}}
style="plainIcon"
onClick={(e) => {
props.performMultiSelectAction(BulkAction.MOVE_TO_FOLDER)
e.preventDefault()
}}
>
<MoveToInboxIcon size={20} color={color} />
</Button>
)
}
type AddLabelsButtonProps = {
setShowLabelsModal: (set: boolean) => void
}

View file

@ -1,6 +1,6 @@
import { LayoutType } from './HomeFeedContainer'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { LibraryItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { LibraryItem } from '../../../lib/networking/library_items/useLibraryItems'
import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Toaster } from 'react-hot-toast'
import TopBarProgress from 'react-topbar-progress-indicator'

View file

@ -8,21 +8,24 @@ import { useFetchMore } from '../../../lib/hooks/useFetchMoreScroll'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
import { libraryListCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts'
import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
import {
PageType,
State,
} from '../../../lib/networking/fragments/articleFragment'
import {
SearchItem,
TypeaheadSearchItemsData,
typeaheadSearchQuery,
} from '../../../lib/networking/queries/typeaheadSearch'
import type {
import {
LibraryItem,
LibraryItemNode,
LibraryItems,
LibraryItemsQueryInput,
} from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { useGetLibraryItemsQuery } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
useArchiveItem,
useBulkActions,
useDeleteItem,
useGetLibraryItems,
useMoveItemToFolder,
useRefreshProcessingItems,
useUpdateItemReadStatus,
} from '../../../lib/networking/library_items/useLibraryItems'
import {
useGetViewerQuery,
UserBasicData,
@ -38,17 +41,10 @@ import { EditLibraryItemModal } from '../homeFeed/EditItemModals'
import { EmptyLibrary } from '../homeFeed/EmptyLibrary'
import { MultiSelectMode } from '../homeFeed/LibraryHeader'
import { UploadModal } from '../UploadModal'
import { BulkAction } from '../../../lib/networking/mutations/bulkActionMutation'
import { bulkActionMutation } from '../../../lib/networking/mutations/bulkActionMutation'
import {
showErrorToast,
showSuccessToast,
showSuccessToastWithAction,
} from '../../../lib/toastHelpers'
import { BulkAction } from '../../../lib/networking/library_items/useLibraryItems'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { SetPageLabelsModalPresenter } from '../article/SetLabelsModalPresenter'
import { NotebookPresenter } from '../article/NotebookPresenter'
import { saveUrlMutation } from '../../../lib/networking/mutations/saveUrlMutation'
import { articleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
import { PinnedButtons } from '../homeFeed/PinnedButtons'
import { PinnedSearch } from '../../../pages/settings/pinned-searches'
import { FetchItemsError } from '../homeFeed/FetchItemsError'
@ -56,6 +52,9 @@ import { LibraryHeader } from './LibraryHeader'
import { TrashIcon } from '../../elements/icons/TrashIcon'
import { theme } from '../../tokens/stitches.config'
import { emptyTrashMutation } from '../../../lib/networking/mutations/emptyTrashMutation'
import { State } from '../../../lib/networking/fragments/articleFragment'
import { useHandleAddUrl } from '../../../lib/hooks/useHandleAddUrl'
import { QueryClient, useQueryClient } from '@tanstack/react-query'
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
@ -72,21 +71,16 @@ const debouncedFetchSearchResults = debounce((query, cb) => {
fetchSearchResults(query, cb)
}, 300)
// We set a relatively high delay for the refresh at the end, as it's likely there's an issue
// in processing. We give it the best attempt to be able to resolve, but if it doesn't we set
// the state as Failed. On refresh it will try again if the backend sends "PROCESSING"
const TIMEOUT_DELAYS = [2000, 3500, 5000]
type LibraryContainerProps = {
folder: string
folder: string | undefined
filterFunc: (item: LibraryItemNode) => boolean
showNavigationMenu: boolean
}
export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
const { viewerData } = useGetViewerQuery()
const router = useRouter()
const { viewerData } = useGetViewerQuery()
const { queryValue } = useKBar((state) => ({ queryValue: state.searchQuery }))
const [searchResults, setSearchResults] = useState<SearchItem[]>([])
@ -110,31 +104,22 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
const [linkToEdit, setLinkToEdit] = useState<LibraryItem>()
const [linkToUnsubscribe, setLinkToUnsubscribe] = useState<LibraryItem>()
const archiveItem = useArchiveItem()
const deleteItem = useDeleteItem()
const moveToFolder = useMoveItemToFolder()
const bulkAction = useBulkActions()
const updateItemReadStatus = useUpdateItemReadStatus()
const [queryInputs, setQueryInputs] =
useState<LibraryItemsQueryInput>(defaultQuery)
const {
itemsPages,
size,
setSize,
isValidating,
performActionOnItem,
mutate,
data: itemsPages,
isLoading,
fetchNextPage,
hasNextPage,
error: fetchItemsError,
} = useGetLibraryItemsQuery(props.folder, queryInputs)
useEffect(() => {
const handleRevalidate = () => {
;(async () => {
console.log('revalidating library')
await mutate()
})()
}
document.addEventListener('revalidateLibrary', handleRevalidate)
return () => {
document.removeEventListener('revalidateLibrary', handleRevalidate)
}
}, [mutate])
} = useGetLibraryItems(props.folder, queryInputs)
useEffect(() => {
if (queryValue.startsWith('#')) {
@ -157,7 +142,7 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
if (qs !== (queryInputs.searchQuery || '')) {
setQueryInputs({ ...queryInputs, searchQuery: qs })
performActionOnItem('refresh', undefined as unknown as any)
// performActionOnItem('refresh', undefined as unknown as any)
}
// intentionally not watching queryInputs and performActionOnItem
@ -169,25 +154,18 @@ 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
?.flatMap((ad) => {
return ad.search.edges.map((it) => ({
itemsPages?.pages
.flatMap((ad: LibraryItems) => {
return ad.edges.map((it) => ({
...it,
isLoading: it.node.state === 'PROCESSING',
}))
})
.filter((item) => props.filterFunc(item.node)) || []
return items
}, [itemsPages, performActionOnItem])
}, [itemsPages])
useEffect(() => {
if (localStorage) {
@ -198,78 +176,22 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
}
}, [libraryItems])
useEffect(() => {
const timeout: NodeJS.Timeout[] = []
const processingItems = useMemo(() => {
return libraryItems
.filter((li) => li.node.state === State.PROCESSING)
.map((li) => li.node.id)
}, [libraryItems])
const items = (
itemsPages?.flatMap((ad) => {
return ad.search.edges.map((it) => ({
...it,
isLoading: it.node.state === 'PROCESSING',
}))
}) || []
).filter((it) => it.isLoading)
items.map(async (item) => {
let startIdx = 0
const seeIfUpdated = async () => {
if (startIdx >= TIMEOUT_DELAYS.length) {
item.node.state = State.FAILED
const updatedArticle = { ...item }
updatedArticle.node = { ...item.node }
updatedArticle.isLoading = false
performActionOnItem('update-item', updatedArticle)
return
}
const username = viewerData?.me?.profile.username
const itemsToUpdate = libraryItems.filter((it) => it.isLoading)
if (itemsToUpdate.length > 0) {
const link = await articleQuery({
username,
slug: item.node.id,
includeFriendsHighlights: false,
})
if (link && link.state != 'PROCESSING') {
const updatedArticle = { ...item }
updatedArticle.node = { ...item.node, ...link }
updatedArticle.isLoading = false
console.log(`Updating Metadata of ${item.node.slug}.`)
performActionOnItem('update-item', updatedArticle)
return
}
console.log(
`Trying to get the metadata of item ${item.node.slug}... Retry ${startIdx} of 5`
)
timeout.push(setTimeout(seeIfUpdated, TIMEOUT_DELAYS[startIdx++]))
}
}
await seeIfUpdated()
})
return () => {
timeout.forEach(clearTimeout)
}
}, [itemsPages])
const handleFetchMore = useCallback(() => {
if (isValidating || !hasMore) {
return
}
setSize(size + 1)
}, [size, isValidating])
const refreshProcessingItems = useRefreshProcessingItems()
useEffect(() => {
if (isValidating || !hasMore || size !== 1) {
return
if (processingItems.length) {
refreshProcessingItems.mutateAsync({
attempt: 0,
itemIds: processingItems,
})
}
setSize(size + 1)
}, [size, isValidating])
}, [processingItems])
const focusFirstItem = useCallback(() => {
if (libraryItems.length < 1) {
@ -374,10 +296,6 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
if (activeCardId && !alreadyScrolled.current) {
scrollToActiveCard(activeCardId)
alreadyScrolled.current = true
if (activeItem) {
performActionOnItem('refresh', activeItem)
}
}
}, [activeCardId, scrollToActiveCard])
@ -397,11 +315,7 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
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)
router.push(`/${username}/${item.node.slug}`)
}
}
break
@ -412,19 +326,94 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
}
break
case 'archive':
performActionOnItem('archive', item)
break
case 'unarchive':
performActionOnItem('unarchive', item)
try {
await archiveItem.mutateAsync({
itemId: item.node.id,
slug: item.node.slug,
input: {
linkId: item.node.id,
archived: action == 'archive',
},
})
} catch (err) {
console.log('Error setting archive state: ', err)
showErrorToast(`Error ${action}ing item`, {
position: 'bottom-right',
})
return
}
showSuccessToast(`Item ${action}d`, {
position: 'bottom-right',
})
break
case 'delete':
performActionOnItem('delete', item)
try {
await deleteItem.mutateAsync({
itemId: item.node.id,
slug: item.node.slug,
})
} catch (err) {
console.log('Error deleting item: ', err)
showErrorToast(`Error deleting item`, {
position: 'bottom-right',
})
return
}
showSuccessToast(`Item deleted`, {
position: 'bottom-right',
})
break
case 'mark-read':
performActionOnItem('mark-read', item)
break
case 'mark-unread':
performActionOnItem('mark-unread', item)
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({
itemId: item.node.id,
slug: item.node.slug,
input: {
id: item.node.id,
force: true,
...values,
},
})
} catch (err) {
console.log('Error marking item: ', err)
showErrorToast(`Error marking as ${desc}`, {
position: 'bottom-right',
})
return
}
break
case 'move-to-inbox':
try {
await moveToFolder.mutateAsync({
itemId: item.node.id,
slug: item.node.slug,
folder: 'inbox',
})
} catch (err) {
console.log('Error moving item: ', err)
showErrorToast(`Error moving item`, {
position: 'bottom-right',
})
return
}
showSuccessToast(`Item moved to library`, {
position: 'bottom-right',
})
break
case 'set-labels':
setLabelsTarget(item)
@ -437,10 +426,10 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
}
break
case 'unsubscribe':
performActionOnItem('unsubscribe', item)
case 'update-item':
performActionOnItem('update-item', item)
// setLinkToUnsubscribe(item.node)
break
default:
console.warn('unknown action: ', action)
}
}
@ -590,19 +579,20 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
})
)
const ARCHIVE_ACTION = !activeItem?.node.isArchived
? createAction({
section: 'Library',
name: 'Archive selected item',
shortcut: ['e'],
perform: () => handleCardAction('archive', activeItem),
})
: createAction({
section: 'Library',
name: 'UnArchive selected item',
shortcut: ['e'],
perform: () => handleCardAction('unarchive', activeItem),
})
const ARCHIVE_ACTION =
activeItem?.node.state !== State.ARCHIVED
? createAction({
section: 'Library',
name: 'Archive selected item',
shortcut: ['e'],
perform: () => handleCardAction('archive', activeItem),
})
: createAction({
section: 'Library',
name: 'UnArchive selected item',
shortcut: ['e'],
perform: () => handleCardAction('unarchive', activeItem),
})
const ACTIVE_ACTIONS = [
ARCHIVE_ACTION,
@ -676,7 +666,7 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
activeCardId ? [...ACTIVE_ACTIONS, ...UNACTIVE_ACTIONS] : UNACTIVE_ACTIONS,
[activeCardId, activeItem]
)
useFetchMore(handleFetchMore)
useFetchMore(fetchNextPage)
const setIsChecked = useCallback(
(itemId: string, set: boolean) => {
@ -710,8 +700,8 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
case 'search':
case 'visible':
const allIds = (
itemsPages?.flatMap((ad) => {
return ad.search.edges
itemsPages?.pages.flatMap((ad) => {
return ad.edges
}) || []
).map((item) => item.node.id)
setCheckedItems(allIds)
@ -739,21 +729,23 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
multiSelectMode === 'search'
? queryInputs.searchQuery || 'in:inbox'
: `includes:${checkedItems.join(',')}`
const expectedCount =
multiSelectMode === 'search'
? itemsPages?.[0].search.pageInfo.totalCount || 0
: checkedItems.length
const expectedCount = checkedItems.length
let bulkArguments = undefined
if (action == BulkAction.MOVE_TO_FOLDER) {
bulkArguments = { folder: 'inbox ' }
}
try {
const res = await bulkActionMutation(
const res = await bulkAction.mutateAsync({
action,
query,
expectedCount,
labelIds
)
labelIds,
arguments: bulkArguments,
})
if (res) {
let successMessage: string | undefined = undefined
console.log(action)
switch (action) {
case BulkAction.ARCHIVE:
successMessage = 'Link Archived'
@ -767,6 +759,9 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
case BulkAction.MARK_AS_READ:
successMessage = 'Items marked as read'
break
case BulkAction.MOVE_TO_FOLDER:
successMessage = 'Items moved to library'
break
}
if (successMessage) {
showSuccessToast(successMessage, { position: 'bottom-right' })
@ -781,37 +776,18 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
position: 'bottom-right',
})
}
mutate()
// mutate()
})()
setMultiSelectMode('off')
},
[itemsPages, multiSelectMode, checkedItems]
)
const handleLinkSubmission = async (
link: string,
timezone: string,
locale: string
) => {
const result = await saveUrlMutation(link, timezone, locale)
if (result) {
showSuccessToastWithAction('Link saved', 'Read now', async () => {
window.location.href = `/article?url=${encodeURIComponent(link)}`
return Promise.resolve()
})
const id = result.url?.match(/[^/]+$/)?.[0] ?? ''
performActionOnItem('refresh', undefined as unknown as any)
} else {
showErrorToast('Error saving link', { position: 'bottom-right' })
}
}
return (
<HomeFeedGrid
folder={props.folder}
items={libraryItems}
actionHandler={handleCardAction}
reloadItems={mutate}
setIsChecked={setIsChecked}
itemIsChecked={itemIsChecked}
multiSelectMode={multiSelectMode}
@ -820,7 +796,6 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
performMultiSelectAction={performMultiSelectAction}
searchTerm={queryInputs.searchQuery}
gridContainerRef={gridContainerRef}
handleLinkSubmission={handleLinkSubmission}
applySearchQuery={(searchQuery: string) => {
setQueryInputs({
...queryInputs,
@ -836,18 +811,11 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
const href = `${window.location.pathname}?${qp.toString()}`
router.push(href, href, { shallow: true })
window.sessionStorage.setItem('q', qp.toString())
performActionOnItem('refresh', undefined as unknown as any)
}}
loadMore={() => {
if (isValidating) {
return
}
setSize(size + 1)
}}
hasMore={hasMore}
loadMore={fetchNextPage}
hasMore={hasNextPage ?? false}
hasData={!!itemsPages}
totalItems={itemsPages?.[0].search.pageInfo.totalCount || 0}
isValidating={isValidating}
isValidating={isLoading}
fetchItemsError={!!fetchItemsError}
labelsTarget={labelsTarget}
setLabelsTarget={setLabelsTarget}
@ -864,25 +832,19 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element {
setLinkToEdit={setLinkToEdit}
linkToUnsubscribe={linkToUnsubscribe}
setLinkToUnsubscribe={setLinkToUnsubscribe}
numItemsSelected={
multiSelectMode == 'search'
? itemsPages?.[0].search.pageInfo.totalCount || 0
: checkedItems.length
}
numItemsSelected={checkedItems.length}
/>
)
}
export type HomeFeedContentProps = {
folder: string
folder: string | undefined
items: LibraryItem[]
searchTerm?: string
reloadItems: () => void
gridContainerRef: React.RefObject<HTMLDivElement>
applySearchQuery: (searchQuery: string) => void
hasMore: boolean
hasData: boolean
totalItems: number
isValidating: boolean
fetchItemsError: boolean
@ -909,12 +871,6 @@ export type HomeFeedContentProps = {
item: LibraryItem | undefined
) => Promise<void>
handleLinkSubmission: (
link: string,
timezone: string,
locale: string
) => Promise<void>
showNavigationMenu: boolean
setIsChecked: (itemId: string, set: boolean) => void
@ -953,6 +909,8 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
return true
}, [props])
const addUrl = useHandleAddUrl()
return (
<VStack
css={{
@ -969,6 +927,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
>
<LibraryHeader
layout={layout}
folder={props.folder}
viewer={viewerData?.me}
updateLayout={updateLayout}
showFilterMenu={props.showNavigationMenu}
@ -1004,7 +963,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
{props.showAddLinkModal && (
<AddLinkModal
handleLinkSubmission={props.handleLinkSubmission}
handleLinkSubmission={addUrl}
onOpenChange={() => props.setShowAddLinkModal(false)}
/>
)}
@ -1014,7 +973,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
}
type LibraryItemsLayoutProps = {
folder: string
folder: string | undefined
layout: LayoutType
viewer?: UserBasicData
@ -1136,6 +1095,9 @@ export function LibraryItemsLayout(
css={{ textDecoration: 'underline' }}
onClick={async (event) => {
event.preventDefault()
alert(
'Emptying trash happens in the background and could take a few minutes depending on the number of items you have in the trash. You may see old items in your trash during this time.'
)
await emptyTrashMutation()
showSuccessToast('Emptying trash')
setTimeout(() => {
@ -1162,7 +1124,7 @@ export function LibraryItemsLayout(
}}
style={{ height: '100%', width: '100%' }}
>
<LibraryItems
<LibraryItemsList
folder={props.folder}
items={props.items}
layout={props.layout}
@ -1200,13 +1162,14 @@ export function LibraryItemsLayout(
</VStack>
{props.showEditTitleModal && (
<EditLibraryItemModal
updateItem={(item: LibraryItem) =>
props.actionHandler('update-item', item)
}
onOpenChange={() => {
props.setShowEditTitleModal(false)
props.setLinkToEdit(undefined)
}}
updateItem={async () => {
await Promise.resolve()
console.log('item updated')
}}
item={props.linkToEdit as LibraryItem}
/>
)}
@ -1219,8 +1182,7 @@ export function LibraryItemsLayout(
)}
{props.labelsTarget?.node.id && (
<SetPageLabelsModalPresenter
articleId={props.labelsTarget.node.id}
article={props.labelsTarget.node}
libraryItem={props.labelsTarget.node}
onOpenChange={() => {
if (props.labelsTarget) {
const activate = props.labelsTarget
@ -1252,7 +1214,7 @@ export function LibraryItemsLayout(
}
type LibraryItemsProps = {
folder: string
folder: string | undefined
items: LibraryItem[]
layout: LayoutType
viewer: UserBasicData | undefined
@ -1274,7 +1236,7 @@ type LibraryItemsProps = {
) => Promise<void>
}
function LibraryItems(props: LibraryItemsProps): JSX.Element {
function LibraryItemsList(props: LibraryItemsProps): JSX.Element {
return (
<Box
ref={props.gridContainerRef}
@ -1283,7 +1245,7 @@ function LibraryItems(props: LibraryItemsProps): JSX.Element {
width: '100%',
gridAutoRows: 'auto',
borderRadius: '6px',
gridGap: props.layout == 'LIST_LAYOUT' ? '10px' : '20px',
gridGap: props.layout == 'LIST_LAYOUT' ? '0px' : '20px',
marginTop: '10px',
marginBottom: '0px',
paddingTop: '0',

View file

@ -9,7 +9,7 @@ import { FunnelSimple, X } from '@phosphor-icons/react'
import { LayoutType } from '../homeFeed/HomeFeedContainer'
import { OmnivoreSmallLogo } from '../../elements/images/OmnivoreNameLogo'
import { LIBRARY_LEFT_MENU_WIDTH } from '../navMenu/LibraryMenu'
import { BulkAction } from '../../../lib/networking/mutations/bulkActionMutation'
import { BulkAction } from '../../../lib/networking/library_items/useLibraryItems'
import { HeaderToggleGridIcon } from '../../elements/icons/HeaderToggleGridIcon'
import { HeaderToggleListIcon } from '../../elements/icons/HeaderToggleListIcon'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
@ -26,6 +26,7 @@ export type LibraryHeaderProps = {
layout: LayoutType
updateLayout: (layout: LayoutType) => void
folder: string | undefined
searchTerm: string | undefined
applySearchQuery: (searchQuery: string) => void
@ -250,26 +251,28 @@ export function SearchBox(props: SearchBoxProps): JSX.Element {
distribution="start"
css={{ width: '100%', height: '100%' }}
>
<HStack
alignment="center"
distribution="center"
css={{
width: '53px',
height: '100%',
display: 'flex',
bg: props.multiSelectMode !== 'off' ? '$ctaBlue' : 'transparent',
borderTopLeftRadius: '6px',
borderBottomLeftRadius: '6px',
'--checkbox-color': 'var(--colors-thLibraryMultiselectCheckbox)',
'&:hover': {
bg: '$thLibraryMultiselectHover',
'--checkbox-color':
'var(--colors-thLibraryMultiselectCheckboxHover)',
},
}}
>
<CheckBoxButton {...props} />
</HStack>
{props.folder !== 'trash' && (
<HStack
alignment="center"
distribution="center"
css={{
width: '53px',
height: '100%',
display: 'flex',
bg: props.multiSelectMode !== 'off' ? '$ctaBlue' : 'transparent',
borderTopLeftRadius: '6px',
borderBottomLeftRadius: '6px',
'--checkbox-color': 'var(--colors-thLibraryMultiselectCheckbox)',
'&:hover': {
bg: '$thLibraryMultiselectHover',
'--checkbox-color':
'var(--colors-thLibraryMultiselectCheckboxHover)',
},
}}
>
<CheckBoxButton {...props} />
</HStack>
)}
<HStack
alignment="center"
distribution="start"

View file

@ -1,57 +1,4 @@
import { Action, createAction, useKBar, useRegisterActions } from 'kbar'
import debounce from 'lodash/debounce'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Toaster } from 'react-hot-toast'
import TopBarProgress from 'react-topbar-progress-indicator'
import { useFetchMore } from '../../../lib/hooks/useFetchMoreScroll'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
import { libraryListCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts'
import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
import {
PageType,
State,
} from '../../../lib/networking/fragments/articleFragment'
import {
SearchItem,
TypeaheadSearchItemsData,
typeaheadSearchQuery,
} from '../../../lib/networking/queries/typeaheadSearch'
import type {
LibraryItem,
LibraryItemsQueryInput,
} from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { useGetLibraryItemsQuery } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import {
useGetViewerQuery,
UserBasicData,
} from '../../../lib/networking/queries/useGetViewerQuery'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { ConfirmationModal } from '../../patterns/ConfirmationModal'
import { LinkedItemCardAction } from '../../patterns/LibraryCards/CardTypes'
import { LinkedItemCard } from '../../patterns/LibraryCards/LinkedItemCard'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { AddLinkModal } from '../AddLinkModal'
import { EditLibraryItemModal } from '../homeFeed/EditItemModals'
import { EmptyLibrary } from '../homeFeed/EmptyLibrary'
import { LegacyLibraryHeader, MultiSelectMode } from '../homeFeed/LibraryHeader'
import { UploadModal } from '../UploadModal'
import { BulkAction } from '../../../lib/networking/mutations/bulkActionMutation'
import { bulkActionMutation } from '../../../lib/networking/mutations/bulkActionMutation'
import {
showErrorToast,
showSuccessToast,
showSuccessToastWithAction,
} from '../../../lib/toastHelpers'
import { SetPageLabelsModalPresenter } from '../article/SetLabelsModalPresenter'
import { NotebookPresenter } from '../article/NotebookPresenter'
import { saveUrlMutation } from '../../../lib/networking/mutations/saveUrlMutation'
import { articleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
import { PinnedButtons } from '../homeFeed/PinnedButtons'
import { PinnedSearch } from '../../../pages/settings/pinned-searches'
import { FetchItemsError } from '../homeFeed/FetchItemsError'
import { LibraryHeader } from './LibraryHeader'
import { VStack } from '../../elements/LayoutPrimitives'
type LibrarySideBarProps = {
text: string

View file

@ -8,19 +8,19 @@ import {
SubscriptionType,
useGetSubscriptionsQuery,
} from '../../../lib/networking/queries/useGetSubscriptionsQuery'
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { theme } from '../../tokens/stitches.config'
import { useRegisterActions } from 'kbar'
import { LogoBox } from '../../elements/LogoBox'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
import { useGetSavedSearchQuery } from '../../../lib/networking/queries/useGetSavedSearchQuery'
import { SavedSearch } from '../../../lib/networking/fragments/savedSearchFragment'
import { ToggleCaretDownIcon } from '../../elements/icons/ToggleCaretDownIcon'
import Link from 'next/link'
import { ToggleCaretRightIcon } from '../../elements/icons/ToggleCaretRightIcon'
import { NavMenuFooter } from './Footer'
import { escapeQuotes } from '../../../utils/helper'
import { useGetLabels } from '../../../lib/networking/labels/useLabels'
import { useGetSavedSearches } from '../../../lib/networking/savedsearches/useSavedSearches'
export const LIBRARY_LEFT_MENU_WIDTH = '275px'
@ -50,17 +50,17 @@ export function LibraryLegacyMenu(props: LibraryFilterMenuProps): JSX.Element {
isSessionStorage: false,
initialValue: [],
})
const labelsResponse = useGetLabelsQuery()
const searchesResponse = useGetSavedSearchQuery()
const labelsResponse = useGetLabels()
const searchesResponse = useGetSavedSearches()
const subscriptionsResponse = useGetSubscriptionsQuery()
useEffect(() => {
if (
!labelsResponse.error &&
!labelsResponse.isLoading &&
labelsResponse.labels
labelsResponse.data
) {
setLabels(labelsResponse.labels)
setLabels(labelsResponse.data)
}
}, [setLabels, labelsResponse])
@ -78,9 +78,9 @@ export function LibraryLegacyMenu(props: LibraryFilterMenuProps): JSX.Element {
if (
!searchesResponse.error &&
!searchesResponse.isLoading &&
searchesResponse.savedSearches
searchesResponse?.data
) {
setSavedSearches(searchesResponse.savedSearches)
setSavedSearches(searchesResponse.data)
}
}, [setSavedSearches, searchesResponse])

View file

@ -6,15 +6,12 @@ import { Circle, DotsThree, MagnifyingGlass, X } from '@phosphor-icons/react'
import {
Subscription,
SubscriptionType,
useGetSubscriptionsQuery,
} from '../../../lib/networking/queries/useGetSubscriptionsQuery'
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { theme } from '../../tokens/stitches.config'
import { useRegisterActions } from 'kbar'
import { LogoBox } from '../../elements/LogoBox'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
import { useGetSavedSearchQuery } from '../../../lib/networking/queries/useGetSavedSearchQuery'
import { SavedSearch } from '../../../lib/networking/fragments/savedSearchFragment'
import { ToggleCaretDownIcon } from '../../elements/icons/ToggleCaretDownIcon'
import Link from 'next/link'
@ -25,13 +22,13 @@ import { HomeIcon } from '../../elements/icons/HomeIcon'
import { LibraryIcon } from '../../elements/icons/LibraryIcon'
import { HighlightsIcon } from '../../elements/icons/HighlightsIcon'
import { CoverImage } from '../../elements/CoverImage'
import { Shortcut } from './NavigationMenu'
import { OutlinedLabelChip } from '../../elements/OutlinedLabelChip'
import { NewsletterIcon } from '../../elements/icons/NewsletterIcon'
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
import { useRouter } from 'next/router'
import { DiscoverIcon } from '../../elements/icons/DiscoverIcon'
import { escapeQuotes } from '../../../utils/helper'
import { Shortcut } from '../../../lib/networking/shortcuts/useShortcuts'
export const LIBRARY_LEFT_MENU_WIDTH = '275px'
@ -185,59 +182,6 @@ const Shortcuts = (props: LibraryFilterMenuProps): JSX.Element => {
initialValue: [],
})
console.log('got shortcuts: ', shortcuts)
// const shortcuts: Shortcut[] = [
// {
// id: '12asdfasdf',
// name: 'Omnivore Blog',
// icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png',
// filter: 'subscription:"Money Talk"',
// type: 'feed',
// },
// {
// id: 'sdfsdfgdsfg',
// name: 'Follow the Money | Arne & Harr',
// filter: 'subscription:"Money Talk"',
// type: 'feed',
// },
// {
// id: 'sdfasdfasdfsdfsdfsgasdfg',
// name: 'Andrew Kenneson from Center for the Study of Partisanship and Ideology',
// // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png',
// filter: 'in:all label:"Hockey"',
// type: 'newsletter',
// },
// {
// id: 'sdfasdfasdfsdfsdfsgasdfg',
// name: 'Robert的博客',
// // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png',
// filter: 'in:all label:"Hockey"',
// type: 'feed',
// },
// {
// id: 'sdfasdfasdfasdfasf',
// name: 'Oldest First',
// // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png',
// filter: 'in:all label:"Hockey"',
// type: 'search',
// },
// {
// id: 'sdfasdfasdfgasdfg',
// name: 'Hockey',
// // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png',
// filter: 'in:all label:"Hockey"',
// type: 'label',
// label: {
// id: 'sdfsdfsdf',
// name: 'Hockey',
// color: '#E98B8B',
// createdAt: new Date(),
// },
// },
// ]
//
return (
<VStack
css={{

View file

@ -1,16 +1,8 @@
import {
CSSProperties,
ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from 'react'
import { ReactNode, useCallback, useRef, useState } from 'react'
import { StyledText } from '../../elements/StyledText'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { DotsThree, List, X, Tag } from '@phosphor-icons/react'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { DotsThree } from '@phosphor-icons/react'
import { theme } from '../../tokens/stitches.config'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
import { NavMenuFooter } from './Footer'
@ -18,49 +10,23 @@ import { FollowingIcon } from '../../elements/icons/FollowingIcon'
import { HomeIcon } from '../../elements/icons/HomeIcon'
import { LibraryIcon } from '../../elements/icons/LibraryIcon'
import { HighlightsIcon } from '../../elements/icons/HighlightsIcon'
import { CoverImage } from '../../elements/CoverImage'
import { NewsletterIcon } from '../../elements/icons/NewsletterIcon'
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
import { useRouter } from 'next/router'
import { NavigationSection } from '../NavigationLayout'
import { NodeApi, SimpleTree, Tree, TreeApi } from 'react-arborist'
import { ListMagnifyingGlass } from '@phosphor-icons/react'
import { TreeApi } from 'react-arborist'
import React from 'react'
import useSWR from 'swr'
import useSWRMutation from 'swr/mutation'
import { fetchEndpoint } from '../../../lib/appConfig'
import { requestHeaders } from '../../../lib/networking/networkHelpers'
import { v4 as uuidv4 } from 'uuid'
import { showErrorToast } from '../../../lib/toastHelpers'
import { OpenMap } from 'react-arborist/dist/module/state/open-slice'
import { ArchiveSectionIcon } from '../../elements/icons/ArchiveSectionIcon'
import { NavMoreButtonDownIcon } from '../../elements/icons/NavMoreButtonDown'
import { NavMoreButtonUpIcon } from '../../elements/icons/NavMoreButtonUp'
import { ShortcutFolderClosed } from '../../elements/icons/ShortcutFolderClosed'
import { TrashSectionIcon } from '../../elements/icons/TrashSectionIcon'
import { ShortcutFolderOpen } from '../../elements/icons/ShortcutFolderOpen'
import useResizeObserver from 'use-resize-observer'
import {
Shortcut,
useResetShortcuts,
} from '../../../lib/networking/shortcuts/useShortcuts'
import { ShortcutsTree } from '../ShortcutsTree'
export const LIBRARY_LEFT_MENU_WIDTH = '275px'
export type ShortcutType = 'search' | 'label' | 'newsletter' | 'feed' | 'folder'
export type Shortcut = {
type: ShortcutType
id: string
name: string
section: string
filter: string
icon?: string
label?: Label
join?: string
children?: Shortcut[]
}
type NavigationMenuProps = {
section: NavigationSection
@ -101,12 +67,12 @@ export function NavigationMenu(props: NavigationMenuProps): JSX.Element {
}}
onClick={(event) => {
// on small screens we want to dismiss the menu after click
if (window.innerWidth <= 768) {
setDismissed(true)
setTimeout(() => {
props.setShowMenu(false)
}, 100)
}
// if (window.innerWidth <= 768) {
// setDismissed(true)
// setTimeout(() => {
// props.setShowMenu(false)
// }, 100)
// }
event.stopPropagation()
}}
>
@ -217,6 +183,7 @@ const LibraryNav = (props: NavigationMenuProps): JSX.Element => {
onClick={(event) => {
setMoreFolderSectionOpen(!moreFolderSectionOpen)
event.preventDefault()
event.stopPropagation()
}}
>
<HStack
@ -267,11 +234,9 @@ const LibraryNav = (props: NavigationMenuProps): JSX.Element => {
}
const Shortcuts = (props: NavigationMenuProps): JSX.Element => {
const router = useRouter()
const treeRef = useRef<TreeApi<Shortcut> | undefined>(undefined)
const { trigger: resetShortcutsTrigger } = useSWRMutation(
'/api/shortcuts',
resetShortcuts
)
const resetShortcuts = useResetShortcuts()
const createNewFolder = useCallback(async () => {
if (treeRef.current) {
@ -283,9 +248,7 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => {
}, [treeRef])
const resetShortcutsToDefault = useCallback(async () => {
resetShortcutsTrigger(null, {
revalidate: true,
})
await resetShortcuts.mutateAsync()
}, [])
return (
@ -323,6 +286,12 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => {
triggerElement={<DotsThree size={20} />}
css={{ ml: 'auto' }}
>
<DropdownOption
onSelect={() => {
router.push(`/settings/shortcuts`)
}}
title="Edit shortcuts"
/>
<DropdownOption
onSelect={resetShortcutsToDefault}
title="Reset to default"
@ -345,6 +314,10 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => {
outline: 'none',
},
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
}}
>
<ShortcutsTree treeRef={treeRef} />
</Box>
@ -352,525 +325,6 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => {
)
}
type ShortcutsTreeProps = {
treeRef: React.MutableRefObject<TreeApi<Shortcut> | undefined>
}
async function getShortcuts(path: string): Promise<Shortcut[]> {
const url = new URL(path, fetchEndpoint)
try {
const response = await fetch(url.toString(), {
method: 'GET',
headers: requestHeaders(),
credentials: 'include',
mode: 'cors',
})
const payload = await response.json()
if ('shortcuts' in payload) {
return payload['shortcuts'] as Shortcut[]
}
return []
} catch (err) {
console.log('error getting shortcuts: ', err)
throw err
}
}
async function setShortcuts(
path: string,
{ arg }: { arg: { shortcuts: Shortcut[] } }
): Promise<Shortcut[]> {
const url = new URL(path, fetchEndpoint)
try {
const response = await fetch(url.toString(), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...requestHeaders(),
},
credentials: 'include',
mode: 'cors',
body: JSON.stringify(arg),
})
const payload = await response.json()
if (!('shortcuts' in payload)) {
throw new Error('Error syncing shortcuts')
}
return payload['shortcuts'] as Shortcut[]
} catch (err) {
showErrorToast('Error syncing shortcut changes.')
}
return arg.shortcuts
}
async function resetShortcuts(path: string): Promise<Shortcut[]> {
const url = new URL(path, fetchEndpoint)
try {
const response = await fetch(url.toString(), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...requestHeaders(),
},
credentials: 'include',
mode: 'cors',
})
const payload = await response.json()
if (!('shortcuts' in payload)) {
throw new Error('Error syncing shortcuts')
}
return payload['shortcuts'] as Shortcut[]
} catch (err) {
showErrorToast('Error syncing shortcut changes.')
}
return []
}
const cachedShortcutsData = (): Shortcut[] | undefined => {
if (typeof localStorage !== 'undefined') {
const str = localStorage.getItem('/api/shortcuts')
if (str) {
return JSON.parse(str) as Shortcut[]
}
}
return undefined
}
const ShortcutsTree = (props: ShortcutsTreeProps): JSX.Element => {
const router = useRouter()
const { ref, width, height } = useResizeObserver()
const { isValidating, data } = useSWR('/api/shortcuts', getShortcuts, {
revalidateOnFocus: false,
fallbackData: cachedShortcutsData(),
onSuccess(data) {
localStorage.setItem('/api/shortcuts', JSON.stringify(data))
},
})
const { trigger, isMutating } = useSWRMutation('/api/shortcuts', setShortcuts)
const [folderOpenState, setFolderOpenState] = usePersistedState<
Record<string, boolean>
>({
key: 'nav-menu-open-state',
isSessionStorage: false,
initialValue: {},
})
const tree = useMemo(() => {
const result = new SimpleTree<Shortcut>((data ?? []) as Shortcut[])
return result
}, [data])
const syncTreeData = (data: Shortcut[]) => {
trigger(
{ shortcuts: data },
{
optimisticData: data,
rollbackOnError: true,
populateCache: (updatedShortcuts) => {
return updatedShortcuts
},
revalidate: false,
}
)
}
const onMove = useCallback(
(args: { dragIds: string[]; parentId: null | string; index: number }) => {
for (const id of args.dragIds) {
tree?.move({ id, parentId: args.parentId, index: args.index })
}
syncTreeData(tree.data)
},
[tree, data]
)
const onCreate = useCallback(
(args: { parentId: string | null; index: number; type: string }) => {
const data = { id: uuidv4(), name: '', type: 'folder' } as any
if (args.type === 'internal') {
data.children = []
}
tree.create({ parentId: args.parentId, index: args.index, data })
syncTreeData(tree.data)
return data
},
[tree, data]
)
const onDelete = useCallback(
(args: { ids: string[] }) => {
args.ids.forEach((id) => tree.drop({ id }))
syncTreeData(tree.data)
},
[tree, data]
)
const onRename = useCallback(
(args: { name: string; id: string }) => {
tree.update({ id: args.id, changes: { name: args.name } as any })
syncTreeData(tree.data)
},
[tree, data]
)
const onToggle = useCallback(
(id: string) => {
if (id && props.treeRef.current) {
const isOpen = props.treeRef.current?.isOpen(id)
const newItem: OpenMap = {}
newItem[id] = isOpen
setFolderOpenState({ ...folderOpenState, ...newItem })
}
},
[props, folderOpenState, setFolderOpenState]
)
const onActivate = useCallback(
(node: NodeApi<Shortcut>) => {
if (node.data.type == 'folder') {
const join = node.data.join
if (join == 'or') {
const query = node.children
?.map((child) => {
return `(${child.data.filter})`
})
.join(' OR ')
}
} else if (node.data.section != null && node.data.filter != null) {
router.push(`/l/${node.data.section}?q=${node.data.filter}`)
}
},
[tree, router]
)
function countTotalShortcuts(shortcuts: Shortcut[]): number {
let total = 0
for (const shortcut of shortcuts) {
// Count the current shortcut
total++
// If the shortcut has children, recursively count them
if (shortcut.children && shortcut.children.length > 0) {
total += countTotalShortcuts(shortcut.children)
}
}
return total
}
const maximumHeight = useMemo(() => {
if (!data) {
return 320
}
return countTotalShortcuts(data as Shortcut[]) * 36
}, [data])
return (
<Box
ref={ref}
css={{
height: maximumHeight,
flexGrow: 1,
minBlockSize: 0,
}}
>
{!isValidating && (
<Tree
ref={props.treeRef}
data={data as Shortcut[]}
onCreate={onCreate}
onMove={onMove}
onDelete={onDelete}
onRename={onRename}
onToggle={onToggle}
onActivate={onActivate}
rowHeight={36}
initialOpenState={folderOpenState}
width={width}
height={maximumHeight}
>
{NodeRenderer}
</Tree>
)}
</Box>
)
}
function NodeRenderer(args: {
style: CSSProperties
node: NodeApi<Shortcut>
tree: TreeApi<Shortcut>
dragHandle?: (el: HTMLDivElement | null) => void
preview?: boolean
}) {
const isSelected = false
const [menuVisible, setMenuVisible] = useState(false)
const [menuOpened, setMenuOpened] = useState(false)
const router = useRouter()
return (
<HStack
ref={args.dragHandle}
alignment="center"
distribution="start"
css={{
pl: `${20 + args.node.level * 15}px`,
mb: '2px',
gap: '10px',
display: 'flex',
width: '100%',
maxWidth: '100%',
height: '34px',
backgroundColor: isSelected ? '$thLibrarySelectionColor' : 'unset',
fontSize: '15px',
fontWeight: 'regular',
fontFamily: '$display',
color: isSelected
? '$thLibraryMenuSecondary'
: '$thLibraryMenuUnselected',
verticalAlign: 'middle',
borderRadius: '3px',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
'&:hover': {
backgroundColor: isSelected
? '$thLibrarySelectionColor'
: '$thBackground4',
},
'&:active': {
outline: 'unset',
backgroundColor: isSelected
? '$thLibrarySelectionColor'
: '$thBackground4',
},
'&:hover [role="hover-menu"]': {
opacity: '1',
},
}}
onMouseEnter={() => {
setMenuVisible(true)
}}
onMouseLeave={() => {
setMenuVisible(false)
}}
title={args.node.data.name}
onClick={(e) => {
// router.push(`/` + props.section)
}}
>
<HStack
css={{
width: '100%',
height: '100%',
}}
distribution="start"
alignment="center"
>
<NodeItemContents node={args.node} />
<SpanBox
role="hover-menu"
css={{
display: 'flex',
ml: 'auto',
mr: '15px',
opacity: menuVisible || menuOpened ? '1' : '0',
}}
>
<Dropdown
side="bottom"
triggerElement={<DotsThree size={20} />}
css={{ ml: 'auto' }}
onOpenChange={(open) => {
setMenuOpened(open)
}}
>
<DropdownOption
onSelect={() => {
args.tree.delete(args.node)
}}
title="Remove"
/>
{/* {args.node.data.type == 'folder' && (
<DropdownOption
onSelect={() => {
args.node.data.join = 'or'
}}
title="Folder query: OR"
/>
)} */}
</Dropdown>
</SpanBox>
</HStack>
</HStack>
)
}
type NodeItemContentsProps = {
node: NodeApi<Shortcut>
}
const NodeItemContents = (props: NodeItemContentsProps): JSX.Element => {
if (props.node.isEditing) {
return (
<input
autoFocus
type="text"
defaultValue={props.node.data.name}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => props.node.reset()}
onKeyDown={(e) => {
if (e.key === 'Escape') {
props.node.reset()
}
if (e.key === 'Enter') {
// props.node.data = {
// id: 'new-folder',
// type: 'folder',
// name: e.currentTarget.value,
// }
props.node.submit(e.currentTarget.value)
props.node.activate()
}
}}
/>
)
}
if (props.node.isLeaf) {
const shortcut = props.node.data
if (shortcut) {
switch (shortcut.type) {
case 'feed':
case 'newsletter':
return (
<SpanBox>
<FeedOrNewsletterShortcut shortcut={shortcut} />
</SpanBox>
)
case 'label':
return (
<Box>
<LabelShortcut shortcut={shortcut} />
</Box>
)
case 'search':
return (
<Box>
<SearchShortcut shortcut={shortcut} />
</Box>
)
}
}
} else {
return (
<HStack
distribution="start"
alignment="center"
css={{ gap: '10px', width: '100%' }}
onClick={(event) => {
props.node.toggle()
event.preventDefault()
}}
>
{props.node.isClosed ? (
<ShortcutFolderClosed
color={theme.colors.thLibraryMenuPrimary.toString()}
/>
) : (
<ShortcutFolderOpen
color={theme.colors.thLibraryMenuPrimary.toString()}
/>
)}
{props.node.data.name}
</HStack>
)
}
return <></>
}
type ShortcutItemProps = {
shortcut: Shortcut
}
const FeedOrNewsletterShortcut = (props: ShortcutItemProps): JSX.Element => {
return (
<HStack
alignment="center"
distribution="start"
css={{ pl: '10px', width: '100%', gap: '10px' }}
key={`search-${props.shortcut.id}`}
>
<HStack
distribution="start"
alignment="center"
css={{ minWidth: '20px' }}
>
{props.shortcut.icon ? (
<CoverImage
src={props.shortcut.icon}
width={20}
height={20}
css={{ borderRadius: '20px' }}
/>
) : props.shortcut.type == 'newsletter' ? (
<NewsletterIcon color="#F59932" size={18} />
) : (
<FollowingIcon color="#F59932" size={21} />
)}
</HStack>
<StyledText style="settingsItem">{props.shortcut.name}</StyledText>
</HStack>
)
}
const SearchShortcut = (props: ShortcutItemProps): JSX.Element => {
return (
<HStack
alignment="center"
distribution="start"
css={{ pl: '10px', width: '100%', gap: '7px' }}
key={`search-${props.shortcut.id}`}
>
<HStack
distribution="start"
alignment="center"
css={{ minWidth: '20px' }}
>
<ListMagnifyingGlass size={17} />
</HStack>
<StyledText style="settingsItem">{props.shortcut.name}</StyledText>
</HStack>
)
}
const LabelShortcut = (props: ShortcutItemProps): JSX.Element => {
// <OutlinedLabelChip
// text={props.shortcut.name}
// color={props.shortcut.label?.color ?? 'gray'}
// />
return (
<HStack
alignment="center"
distribution="start"
css={{ width: '100%', gap: '7px' }}
key={`search-${props.shortcut.id}`}
>
<Tag
size={15}
color={props.shortcut.label?.color ?? 'gray'}
weight="fill"
/>
<StyledText style="settingsItem" css={{ pb: '1px' }}>
{props.shortcut.name}
</StyledText>
</HStack>
)
}
type NavButtonProps = {
text: string
icon: ReactNode
@ -925,8 +379,7 @@ function NavButton(props: NavButtonProps): JSX.Element {
}}
title={props.text}
onClick={(e) => {
// console.log('clicked navigation menu')
router.push(`/l/` + props.section)
router.push(`/` + props.section)
}}
>
{props.icon}

View file

@ -8,8 +8,6 @@ import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { styled, theme } from '../../tokens/stitches.config'
import { SettingsLayout } from '../SettingsLayout'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
import { FeatureHelpBox } from '../../elements/FeatureHelpBox'
// Styles
export const Header = styled(Box, {
@ -26,8 +24,6 @@ type SettingsTableProps = {
createTitle?: string
createAction?: () => void
suggestionInfo: SuggestionInfo
children: React.ReactNode
}
@ -63,16 +59,6 @@ type MoreOptionsProps = {
onEdit?: () => void
}
type SuggestionInfo = {
title: string
message: string
docs: string
key: string
CTAText?: string
onClickCTA?: () => void
}
const MoreOptions = (props: MoreOptionsProps) => (
<Dropdown
align={'end'}
@ -294,11 +280,6 @@ const CreateButton = (props: CreateButtonProps): JSX.Element => {
}
export const SettingsTable = (props: SettingsTableProps): JSX.Element => {
const [showSuggestion, setShowSuggestion] = usePersistedState<boolean>({
key: props.suggestionInfo.key,
initialValue: !!props.suggestionInfo,
})
return (
<SettingsLayout>
<Toaster
@ -327,19 +308,6 @@ export const SettingsTable = (props: SettingsTableProps): JSX.Element => {
},
}}
>
{props.suggestionInfo && showSuggestion && (
<FeatureHelpBox
helpTitle={props.suggestionInfo.title}
helpMessage={props.suggestionInfo.message}
docsMessage={'Read the Docs'}
docsDestination={props.suggestionInfo.docs}
onDismiss={() => {
setShowSuggestion(false)
}}
helpCTAText={props.suggestionInfo.CTAText}
onClickCTA={props.suggestionInfo.onClickCTA}
/>
)}
<Box
css={{
width: '100%',

View file

@ -453,10 +453,10 @@ const sepiaThemeSpec = {
readerFontHighContrast: '#0A0806',
readerTableHeader: '#FFFFFF',
thLeftMenuBackground: '#EEE8D5',
thNavMenuFooter: '#DDD6C1',
thLeftMenuBackground: '#F8F1E0',
thNavMenuFooter: '#EEE8D5',
thLibrarySelectionColor: '#DDD6C1',
thLibrarySelectionColor: '#EEE8D5',
thLabelChipBackground: '#EEE8D5',
thBackground4: '#DDD6C166', // used on hover of menu items
thBorderColor: '#DDD6C1',

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

@ -0,0 +1,26 @@
import { useCallback } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { useAddItem } from '../networking/library_items/useLibraryItems'
import { showErrorToast, showSuccessToastWithAction } from '../toastHelpers'
export const useHandleAddUrl = () => {
const addItem = useAddItem()
return useCallback(async (url: string, timezone: string, locale: string) => {
const itemId = uuidv4()
const result = await addItem.mutateAsync({
itemId,
url,
timezone,
locale,
})
console.log('result: ', result)
if (result) {
showSuccessToastWithAction('Item saving', 'Read now', async () => {
window.location.href = `/article?url=${encodeURIComponent(url)}`
return Promise.resolve()
})
} else {
showErrorToast('Error saving url', { position: 'bottom-right' })
}
}, [])
}

View file

@ -1,20 +1,30 @@
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,
useRestoreItem,
} from '../networking/library_items/useLibraryItems'
export default function useLibraryItemActions() {
const archiveItem = useCallback(async (itemId: string) => {
const result = await setLinkArchivedMutation({
linkId: itemId,
archived: true,
const archiveItem = useArchiveItem()
const deleteItem = useDeleteItem()
const moveItem = useMoveItemToFolder()
const restoreItem = useRestoreItem()
const doArchiveItem = useCallback(async (itemId: string, slug: string) => {
const result = await archiveItem.mutateAsync({
itemId: itemId,
slug: slug,
input: {
linkId: itemId,
archived: true,
},
})
console.log('result: ', result)
@ -27,33 +37,37 @@ 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, slug: string, undo: () => void) => {
const result = await deleteItem.mutateAsync({ itemId, slug })
if (result) {
showSuccessToastWithUndo('Item removed', async () => {
const result = await updatePageMutation({
pageId: itemId,
state: State.SUCCEEDED,
if (result) {
showSuccessToastWithUndo('Item removed', async () => {
const result = await restoreItem.mutateAsync({ itemId, slug })
undo()
if (result) {
showSuccessToast('Item recovered')
} else {
showErrorToast('Error recovering, check your deleted items')
}
})
} else {
showErrorToast('Error removing item', { position: 'bottom-right' })
}
undo()
return !!result
},
[]
)
if (result) {
showSuccessToast('Item recovered')
} else {
showErrorToast('Error recovering, check your deleted items')
}
})
} else {
showErrorToast('Error removing item', { position: 'bottom-right' })
}
return !!result
}, [])
const moveItem = useCallback(async (itemId: string) => {
const result = await moveToFolderMutation(itemId, 'inbox')
const doMoveItem = useCallback(async (itemId: string, slug: string) => {
const result = await moveItem.mutateAsync({
itemId,
slug,
folder: 'inbox',
})
if (result) {
showSuccessToast('Moved to library', { position: 'bottom-right' })
} else {
@ -85,5 +99,10 @@ export default function useLibraryItemActions() {
[]
)
return { archiveItem, deleteItem, moveItem, shareItem }
return {
archiveItem: doArchiveItem,
deleteItem: doDeleteItem,
moveItem: doMoveItem,
shareItem,
}
}

View file

@ -2,7 +2,7 @@ import { useRegisterActions } from 'kbar'
import { useCallback, useState } from 'react'
import { applyStoredTheme } from '../themeUpdater'
import { usePersistedState } from './usePersistedState'
import { TextDirection } from '../networking/queries/useGetArticleQuery'
import { TextDirection } from '../networking/library_items/useLibraryItems'
const DEFAULT_FONT = 'Inter'

View file

@ -1,8 +1,8 @@
import { useCallback, useEffect, useReducer } from 'react'
import { setLabelsMutation } from '../networking/mutations/setLabelsMutation'
import { Label } from '../networking/fragments/labelFragment'
import { showErrorToast } from '../toastHelpers'
import throttle from 'lodash/throttle'
import { useSetItemLabels } from '../networking/library_items/useLibraryItems'
export type LabelAction = 'RESET' | 'TEMP' | 'SAVE'
export type LabelsDispatcher = (action: {
@ -11,13 +11,22 @@ export type LabelsDispatcher = (action: {
}) => void
export const useSetPageLabels = (
articleId?: string
libraryItemId?: string,
libraryItemSlug?: string
): [{ labels: Label[] }, LabelsDispatcher] => {
const saveLabels = (labels: Label[], articleId: string) => {
const setItemLabels = useSetItemLabels()
const saveLabels = (
labels: Label[],
libraryItemId: string,
libraryItemSlug: string
) => {
;(async () => {
const labelIds = labels.map((l) => l.id)
if (articleId) {
const result = await setLabelsMutation(articleId, labelIds)
if (libraryItemId) {
const result = await setItemLabels.mutateAsync({
itemId: libraryItemId,
slug: libraryItemSlug,
labels,
})
if (!result) {
showErrorToast('Error saving labels', {
position: 'bottom-right',
@ -31,12 +40,14 @@ export const useSetPageLabels = (
state: {
labels: Label[]
articleId: string | undefined
throttledSave: (labels: Label[], articleId: string) => void
slug: string | undefined
throttledSave: (labels: Label[], articleId: string, slug: string) => void
},
action: {
type: string
labels: Label[]
articleId?: string
slug?: string
}
) => {
switch (action.type) {
@ -53,8 +64,8 @@ export const useSetPageLabels = (
}
}
case 'SAVE': {
if (state.articleId) {
state.throttledSave(action.labels, state.articleId)
if (state.articleId && state.slug) {
state.throttledSave(action.labels, state.articleId, state.slug)
} else {
showErrorToast('Unable to update labels', {
position: 'bottom-right',
@ -68,6 +79,7 @@ export const useSetPageLabels = (
case 'UPDATE_ARTICLE_ID': {
return {
...state,
slug: action.slug,
articleId: action.articleId,
}
}
@ -78,7 +90,8 @@ export const useSetPageLabels = (
const debouncedSave = useCallback(
throttle(
(labels: Label[], articleId: string) => saveLabels(labels, articleId),
(labels: Label[], articleId: string, slug: string) =>
saveLabels(labels, articleId, slug),
2000
),
[]
@ -88,13 +101,15 @@ export const useSetPageLabels = (
dispatchLabels({
type: 'UPDATE_ARTICLE_ID',
labels: [],
articleId: articleId,
slug: libraryItemSlug,
articleId: libraryItemId,
})
}, [articleId])
}, [libraryItemId])
const [labels, dispatchLabels] = useReducer(labelsReducer, {
labels: [],
articleId: articleId,
articleId: libraryItemId,
slug: libraryItemSlug,
throttledSave: debouncedSave,
})

View file

@ -13,7 +13,7 @@ export function navigationCommands(router: NextRouter | undefined): Action[] {
keywords: 'go home',
perform: () => {
console.log('go home')
router?.push(`/l/home`)
router?.push(`/home`)
},
},
{
@ -24,7 +24,7 @@ export function navigationCommands(router: NextRouter | undefined): Action[] {
keywords: 'go library',
perform: () => {
console.log('go library')
router?.push(`/l/library`)
router?.push(`/library`)
},
},
{
@ -35,7 +35,7 @@ export function navigationCommands(router: NextRouter | undefined): Action[] {
keywords: 'go subscriptions',
perform: () => {
console.log('go subscriptions')
router?.push(`/l/subscriptions`)
router?.push(`/subscriptions`)
},
},
{
@ -46,7 +46,7 @@ export function navigationCommands(router: NextRouter | undefined): Action[] {
keywords: 'go highlights',
perform: () => {
console.log('go highlights')
router?.push(`/l/highlights`)
router?.push(`/highlights`)
},
},
{

View file

@ -17,7 +17,6 @@ export const articleFragment = gql`
readingProgressAnchorIndex
slug
folder
isArchived
description
linkId
state
@ -60,7 +59,6 @@ export type ArticleFragmentData = {
readingProgressTopPercent?: number
readingProgressAnchorIndex: number
slug: string
isArchived: boolean
description: string
linkId?: string
state?: State

View file

@ -1,5 +1,5 @@
import { gql } from 'graphql-request'
import { LibraryItemNode } from '../queries/useGetLibraryItemsQuery'
import { LibraryItemNode } from '../library_items/useLibraryItems'
import { Label } from './labelFragment'
export const highlightFragment = gql`

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,233 @@
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
slug: string | undefined
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,
variables.slug,
(item) => {
return {
...item,
highlights: [...item.highlights, newHighlight],
}
}
)
}
},
})
}
export const useDeleteHighlight = () => {
const queryClient = useQueryClient()
const deleteHighlight = async (variables: {
itemId: string
slug: 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,
variables.slug,
(item) => {
return {
...item,
highlights: item.highlights.filter(
(h) => h.id != deletedHighlight.id
),
}
}
)
}
},
})
}
export const useUpdateHighlight = () => {
const queryClient = useQueryClient()
const updateHighlight = async (variables: {
itemId: string
slug: string | undefined
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,
variables.slug,
(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
slug: 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,
variables.slug,
(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

@ -0,0 +1,71 @@
import { gql } from 'graphql-request'
import { labelFragment } from '../fragments/labelFragment'
export const GQL_GET_LABELS = gql`
query GetLabels {
labels {
... on LabelsSuccess {
labels {
...LabelFields
}
}
... on LabelsError {
errorCodes
}
}
}
${labelFragment}
`
export const GQL_CREATE_LABEL = gql`
mutation CreateLabel($input: CreateLabelInput!) {
createLabel(input: $input) {
... on CreateLabelSuccess {
label {
id
name
color
description
createdAt
}
}
... on CreateLabelError {
errorCodes
}
}
}
`
export const GQL_DELETE_LABEL = gql`
mutation DeleteLabel($id: ID!) {
deleteLabel(id: $id) {
... on DeleteLabelSuccess {
label {
id
}
}
... on DeleteLabelError {
errorCodes
}
}
}
`
export const GQL_UPDATE_LABEL = gql`
mutation UpdateLabel($input: UpdateLabelInput!) {
updateLabel(input: $input) {
... on UpdateLabelSuccess {
label {
id
name
color
description
createdAt
}
}
... on UpdateLabelError {
errorCodes
}
}
}
`

View file

@ -0,0 +1,159 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { gqlFetcher } from '../networkHelpers'
import {
GQL_CREATE_LABEL,
GQL_DELETE_LABEL,
GQL_GET_LABELS,
GQL_UPDATE_LABEL,
} from './gql'
import { Label } from '../fragments/labelFragment'
export function useGetLabels() {
return useQuery({
queryKey: ['labels'],
queryFn: async () => {
const response = (await gqlFetcher(GQL_GET_LABELS)) as LabelsData
if (response.labels?.errorCodes?.length) {
throw new Error(response.labels.errorCodes[0])
}
return response.labels?.labels
},
})
}
export const useCreateLabel = () => {
const queryClient = useQueryClient()
const createLabel = async (variables: {
name: string
color: string
description: string | undefined
}) => {
const result = (await gqlFetcher(GQL_CREATE_LABEL, {
input: {
name: variables.name,
color: variables.color,
description: variables.description,
},
})) as CreateLabelData
if (result.createLabel.errorCodes?.length) {
throw new Error(result.createLabel.errorCodes[0])
}
return result.createLabel.label
}
return useMutation({
mutationFn: createLabel,
onSuccess: (newLabel) => {
const keys = queryClient.getQueryCache().findAll({ queryKey: ['labels'] })
keys.forEach((query) => {
queryClient.setQueryData(query.queryKey, (data: Label[]) => {
return [...data, newLabel]
})
})
},
})
}
export const useDeleteLabel = () => {
const queryClient = useQueryClient()
const deleteLabel = async (variables: { labelId: string }) => {
const result = (await gqlFetcher(GQL_DELETE_LABEL, {
id: variables.labelId,
})) as DeleteLabelData
if (result.deleteLabel.errorCodes?.length) {
throw new Error(result.deleteLabel.errorCodes[0])
}
return result.deleteLabel?.label?.id
}
return useMutation({
mutationFn: deleteLabel,
onSuccess: (deletedId) => {
if (deletedId) {
const keys = queryClient
.getQueryCache()
.findAll({ queryKey: ['labels'] })
keys.forEach((query) => {
queryClient.setQueryData(query.queryKey, (data: Label[]) => {
return data.filter((label) => label.id !== deletedId)
})
})
}
},
})
}
export const useUpdateLabel = () => {
const queryClient = useQueryClient()
const updateLabel = async (variables: {
labelId: string
name: string
color: string
description: string
}) => {
const result = (await gqlFetcher(GQL_UPDATE_LABEL, {
input: {
labelId: variables.labelId,
name: variables.name,
color: variables.color,
description: variables.description,
},
})) as UpdateLabelData
if (result.updateLabel.errorCodes?.length) {
throw new Error(result.updateLabel.errorCodes[0])
}
return result.updateLabel?.label
}
return useMutation({
mutationFn: updateLabel,
onSuccess: (updatedLabel) => {
if (updatedLabel) {
const keys = queryClient
.getQueryCache()
.findAll({ queryKey: ['labels'] })
keys.forEach((query) => {
queryClient.setQueryData(query.queryKey, (data: Label[]) => {
return [
...data.filter((label) => label.id !== updatedLabel.id),
updatedLabel,
]
})
})
}
},
})
}
type LabelsResult = {
labels?: Label[]
errorCodes?: string[]
}
type LabelsData = {
labels?: LabelsResult
}
type CreateLabelResult = {
label?: Label
errorCodes?: string[]
}
type CreateLabelData = {
createLabel: CreateLabelResult
}
type DeleteLabelResult = {
label?: Label
errorCodes?: string[]
}
type DeleteLabelData = {
deleteLabel: DeleteLabelResult
}
type UpdateLabelResult = {
label?: Label
errorCodes?: string[]
}
type UpdateLabelData = {
updateLabel: UpdateLabelResult
}

View file

@ -0,0 +1,307 @@
import { gql } from 'graphql-request'
import { highlightFragment } from '../fragments/highlightFragment'
import { articleFragment } from '../fragments/articleFragment'
import { labelFragment } from '../fragments/labelFragment'
export const recommendationFragment = gql`
fragment RecommendationFields on Recommendation {
id
name
note
user {
userId
name
username
profileImageURL
}
recommendedAt
}
`
export const GQL_SEARCH_QUERY = gql`
query Search(
$after: String
$first: Int
$query: String
$includeContent: Boolean
) {
search(
first: $first
after: $after
query: $query
includeContent: $includeContent
) {
... on SearchSuccess {
edges {
cursor
node {
id
title
slug
url
folder
pageType
contentReader
createdAt
readingProgressPercent
readingProgressTopPercent
readingProgressAnchorIndex
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
id
name
color
}
pageId
shortId
quote
annotation
state
siteName
siteIcon
subscription
readAt
savedAt
wordsCount
recommendations {
id
name
note
user {
userId
name
username
profileImageURL
}
recommendedAt
}
highlights {
...HighlightFields
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on SearchError {
errorCodes
}
}
}
${highlightFragment}
`
export const GQL_SET_LINK_ARCHIVED = gql`
mutation SetLinkArchived($input: ArchiveLinkInput!) {
setLinkArchived(input: $input) {
... on ArchiveLinkSuccess {
linkId
message
}
... on ArchiveLinkError {
message
errorCodes
}
}
}
`
export const GQL_DELETE_LIBRARY_ITEM = gql`
mutation SetBookmarkArticle($input: SetBookmarkArticleInput!) {
setBookmarkArticle(input: $input) {
... on SetBookmarkArticleSuccess {
bookmarkedArticle {
id
}
}
... on SetBookmarkArticleError {
errorCodes
}
}
}
`
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_SET_LABELS = gql`
mutation SetLabels($input: SetLabelsInput!) {
setLabels(input: $input) {
... on SetLabelsSuccess {
labels {
...LabelFields
}
}
... on SetLabelsError {
errorCodes
}
}
}
${labelFragment}
`
export const GQL_SAVE_ARTICLE_READING_PROGRESS = gql`
mutation SaveArticleReadingProgress(
$input: SaveArticleReadingProgressInput!
) {
saveArticleReadingProgress(input: $input) {
... on SaveArticleReadingProgressSuccess {
updatedArticle {
id
readingProgressPercent
readingProgressAnchorIndex
}
}
... on SaveArticleReadingProgressError {
errorCodes
}
}
}
`
export const GQL_UPDATE_LIBRARY_ITEM = gql`
mutation UpdatePage($input: UpdatePageInput!) {
updatePage(input: $input) {
... on UpdatePageSuccess {
updatedPage {
id
title
url
createdAt
author
image
description
savedAt
publishedAt
}
}
... on UpdatePageError {
errorCodes
}
}
}
`
export const GQL_GET_LIBRARY_ITEM = gql`
query GetArticle(
$username: String!
$slug: String!
$includeFriendsHighlights: Boolean
) {
article(username: $username, slug: $slug) {
... on ArticleSuccess {
article {
...ArticleFields
highlights(input: { includeFriends: $includeFriendsHighlights }) {
...HighlightFields
}
labels {
...LabelFields
}
recommendations {
...RecommendationFields
}
}
}
... on ArticleError {
errorCodes
}
}
}
${articleFragment}
${highlightFragment}
${labelFragment}
${recommendationFragment}
`
export const GQL_GET_LIBRARY_ITEM_CONTENT = gql`
query GetArticle(
$username: String!
$slug: String!
$includeFriendsHighlights: Boolean
) {
article(username: $username, slug: $slug) {
... on ArticleSuccess {
article {
...ArticleFields
content
highlights(input: { includeFriends: $includeFriendsHighlights }) {
...HighlightFields
}
labels {
...LabelFields
}
recommendations {
...RecommendationFields
}
}
}
... on ArticleError {
errorCodes
}
}
}
${articleFragment}
${highlightFragment}
${labelFragment}
${recommendationFragment}
`
export const GQL_BULK_ACTION = gql`
mutation BulkAction(
$action: BulkActionType!
$query: String!
$expectedCount: Int
$labelIds: [ID!]
) {
bulkAction(
query: $query
action: $action
labelIds: $labelIds
expectedCount: $expectedCount
) {
... on BulkActionSuccess {
success
}
... on BulkActionError {
errorCodes
}
}
}
`
export const GQL_SAVE_URL = gql`
mutation SaveUrl($input: SaveUrlInput!) {
saveUrl(input: $input) {
... on SaveSuccess {
url
clientRequestId
}
... on SaveError {
errorCodes
message
}
}
}
`

File diff suppressed because it is too large Load diff

View file

@ -9,32 +9,32 @@ export type ArticleReadingProgressMutationInput = {
readingProgressAnchorIndex?: number
}
export async function articleReadingProgressMutation(
input: ArticleReadingProgressMutationInput
): Promise<boolean> {
const mutation = gql`
mutation SaveArticleReadingProgress(
$input: SaveArticleReadingProgressInput!
) {
saveArticleReadingProgress(input: $input) {
... on SaveArticleReadingProgressSuccess {
updatedArticle {
id
readingProgressPercent
readingProgressAnchorIndex
}
}
... on SaveArticleReadingProgressError {
errorCodes
}
}
}
`
// export async function articleReadingProgressMutation(
// input: ArticleReadingProgressMutationInput
// ): Promise<boolean> {
// const mutation = gql`
// mutation SaveArticleReadingProgress(
// $input: SaveArticleReadingProgressInput!
// ) {
// saveArticleReadingProgress(input: $input) {
// ... on SaveArticleReadingProgressSuccess {
// updatedArticle {
// id
// readingProgressPercent
// readingProgressAnchorIndex
// }
// }
// ... on SaveArticleReadingProgressError {
// errorCodes
// }
// }
// }
// `
try {
await gqlFetcher(mutation, { input })
return true
} catch {
return false
}
}
// try {
// await gqlFetcher(mutation, { input })
// return true
// } catch {
// return false
// }
// }

View file

@ -1,62 +1,2 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export enum BulkAction {
ARCHIVE = 'ARCHIVE',
DELETE = 'DELETE',
ADD_LABELS = 'ADD_LABELS',
MARK_AS_READ = 'MARK_AS_READ',
}
type BulkActionResponseData = {
success: boolean
}
type BulkActionResponse = {
errorCodes?: string[]
bulkAction?: BulkActionResponseData
}
export async function bulkActionMutation(
action: BulkAction,
query: string,
expectedCount: number,
labelIds?: string[]
): Promise<boolean> {
const mutation = gql`
mutation BulkAction(
$action: BulkActionType!
$query: String!
$expectedCount: Int
$labelIds: [ID!]
) {
bulkAction(
query: $query
action: $action
labelIds: $labelIds
expectedCount: $expectedCount
) {
... on BulkActionSuccess {
success
}
... on BulkActionError {
errorCodes
}
}
}
`
try {
const response = await gqlFetcher(mutation, {
action,
query,
labelIds,
expectedCount,
})
const data = response as BulkActionResponse | undefined
return data?.bulkAction?.success ?? false
} catch (error) {
console.error(error)
return false
}
}

View file

@ -1,64 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import {
Highlight,
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
}
type CreateHighlightOutput = {
createHighlight: InnerCreateHighlightOutput
}
type InnerCreateHighlightOutput = {
highlight: Highlight
}
export async function createHighlightMutation(
input: CreateHighlightInput
): Promise<Highlight | undefined> {
const mutation = gql`
mutation CreateHighlight($input: CreateHighlightInput!) {
createHighlight(input: $input) {
... on CreateHighlightSuccess {
highlight {
...HighlightFields
}
}
... on CreateHighlightError {
errorCodes
}
}
}
${highlightFragment}
`
try {
const data = await gqlFetcher(mutation, { input })
const output = data as CreateHighlightOutput | undefined
return output?.createHighlight.highlight
} catch {
return undefined
}
}

View file

@ -1,51 +0,0 @@
import { gql } from 'graphql-request'
import { Label } from '../fragments/labelFragment'
import { gqlFetcher } from '../networkHelpers'
type CreateLabelResult = {
createLabel: CreateLabel
errorCodes?: unknown[]
}
type CreateLabel = {
label: Label
}
export async function createLabelMutation(
name: string,
color: string,
description?: string
): Promise<any | undefined> {
const mutation = gql`
mutation CreateLabel($input: CreateLabelInput!) {
createLabel(input: $input) {
... on CreateLabelSuccess {
label {
id
name
color
description
createdAt
}
}
... on CreateLabelError {
errorCodes
}
}
}
`
try {
const data = (await gqlFetcher(mutation, {
input: {
name,
color,
description,
},
})) as CreateLabelResult
return data.errorCodes ? undefined : data.createLabel.label
} catch (error) {
console.log('createLabelMutation error', error)
return undefined
}
}

View file

@ -1,47 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export enum ReminderType {
Tonight = 'TONIGHT',
Tomorrow = 'TOMORROW',
ThisWeekend = 'THIS_WEEKEND',
NextWeek = 'NEXT_WEEK',
}
export async function createReminderMutation(
linkId: string,
reminderType: ReminderType,
archiveUntil: boolean,
sendNotification: boolean
): Promise<string | undefined> {
const mutation = gql`
mutation createReminderMutation($input: CreateReminderInput!) {
createReminder(input: $input) {
... on CreateReminderSuccess {
reminder {
id
remindAt
}
}
... on CreateReminderError {
errorCodes
}
}
}
`
try {
const input = {
linkId,
reminderType,
archiveUntil,
sendNotification,
scheduledAt: new Date(),
}
const data = await gqlFetcher(mutation, { input })
return 'data'
} catch (error) {
console.log('createReminder error', error)
return undefined
}
}

View file

@ -1,36 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { SavedSearch } from "../fragments/savedSearchFragment"
export type DeleteFilterInput = string
type DeleteFilterOutput = {
deleteFilter: { filter: SavedSearch }
}
export async function deleteFilterMutation (
id: DeleteFilterInput
): Promise<SavedSearch | undefined> {
const mutation = gql`
mutation DeleteFilter($id: ID!) {
deleteFilter(id: $id) {
... on DeleteFilterSuccess {
filter {
id
}
}
... on DeleteFilterError {
errorCodes
}
}
}
`
try {
const data = await gqlFetcher(mutation, { id })
const output = data as DeleteFilterOutput | undefined
return output?.deleteFilter.filter
} catch {
return undefined
}
}

View file

@ -1,45 +0,0 @@
import { gql } from 'graphql-request'
import { Label } from '../fragments/labelFragment'
import { gqlFetcher } from '../networkHelpers'
type DeleteLabelResult = {
deleteLabel: DeleteLabel
errorCodes?: unknown[]
}
type DeleteLabel = {
label: Label
}
export async function deleteLabelMutation(
labelId: string
): Promise<any | undefined> {
const mutation = gql`
mutation DeleteLabel($id: ID!) {
deleteLabel(id: $id) {
... on DeleteLabelSuccess {
label {
id
name
color
description
createdAt
}
}
... on DeleteLabelError {
errorCodes
}
}
}
`
try {
const data = (await gqlFetcher(mutation, {
id: labelId,
})) as DeleteLabelResult
return data.errorCodes ? undefined : data.deleteLabel.label.id
} catch (error) {
console.log('deleteLabelMutation error', error)
return undefined
}
}

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,46 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { SavedSearch } from "../fragments/savedSearchFragment"
export type AddFilterInput = {
name: string
filter: string
category: string
position: number
folder?: string
}
type AddFilterOutput = {
saveFilter: { filter: SavedSearch }
}
export async function saveFilterMutation (
input: AddFilterInput
): Promise<SavedSearch | undefined> {
const mutation = gql`
mutation SaveFilter($input: SaveFilterInput!) {
saveFilter(input: $input) {
... on SaveFilterSuccess {
filter {
id
name
filter
position
visible
defaultFilter
folder
category
}
}
... on SaveFilterError {
errorCodes
}
}
}
`
const data = await gqlFetcher(mutation, { input })
const output = data as AddFilterOutput | undefined
return output?.saveFilter.filter
}

View file

@ -1,43 +0,0 @@
import { gql } from 'graphql-request'
import { Label, labelFragment } from '../fragments/labelFragment'
import { gqlFetcher } from '../networkHelpers'
type SetLabelsResult = {
setLabels: SetLabels
}
type SetLabels = {
labels: Label[]
errorCodes?: unknown[]
}
export async function setLabelsMutation(
pageId: string,
labelIds: string[]
): Promise<Label[] | undefined> {
const mutation = gql`
mutation SetLabels($input: SetLabelsInput!) {
setLabels(input: $input) {
... on SetLabelsSuccess {
labels {
...LabelFields
}
}
... on SetLabelsError {
errorCodes
}
}
}
${labelFragment}
`
try {
const data = (await gqlFetcher(mutation, {
input: { pageId, labelIds },
})) as SetLabelsResult
return data.setLabels.errorCodes ? undefined : data.setLabels.labels
} catch (error) {
console.log(' -- SetLabelsOutput error', error)
return undefined
}
}

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,33 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
type ShareHighlightToFeedMutationInput = {
id: string
share: boolean
}
export async function shareHighlightToFeedMutation(
input: ShareHighlightToFeedMutationInput
): Promise<boolean> {
const mutation = gql`
mutation SetShareHighlight($input: SetShareHighlightInput!) {
setShareHighlight(input: $input) {
... on SetShareHighlightSuccess {
highlight {
id
}
}
... on SetShareHighlightError {
errorCodes
}
}
}
`
try {
await gqlFetcher(mutation, { input })
return true
} catch {
return false
}
}

View file

@ -1,47 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { SavedSearch } from "../fragments/savedSearchFragment"
export type UpdateFilterInput = {
id?: string
name?: string
filter?: string
position?: number
category?: string
description?: string
visible?: boolean
folder?: string
}
type UpdateFilterOutput = {
filter: SavedSearch
}
export async function updateFilterMutation (
input: UpdateFilterInput
): Promise<string | undefined> {
const mutation = gql`
mutation UpdateFilter($input: UpdateFilterInput!) {
updateFilter(input: $input) {
... on UpdateFilterSuccess {
filter {
id
}
}
... on UpdateFilterError {
errorCodes
}
}
}
`
try {
const { id, name, visible, filter, position } = input
const data = await gqlFetcher(mutation, { input: {id, name, filter, position, visible }})
const output = data as UpdateFilterOutput | undefined
return output?.filter?.id
} catch {
return undefined
}
}

View file

@ -1,42 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export type UpdateLabelInput = {
labelId: string
name: string
color: string
description?: string
}
export async function updateLabelMutation(
input: UpdateLabelInput
): Promise<string | undefined> {
const mutation = gql`
mutation UpdateLabel($input: UpdateLabelInput!) {
updateLabel(input: $input) {
... on UpdateLabelSuccess {
label {
id
name
color
description
createdAt
}
}
... on UpdateLabelError {
errorCodes
}
}
}
`
try {
const data = await gqlFetcher(mutation, {
input,
})
const output = data as any
return output?.updatedLabel
} catch (err) {
return undefined
}
}

View file

@ -1,50 +0,0 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { State } from '../fragments/articleFragment'
export type UpdatePageInput = {
pageId: string
title?: string
byline?: string | undefined
description?: string
savedAt?: string
publishedAt?: string
state?: State
}
export async function updatePageMutation(
input: UpdatePageInput
): Promise<string | undefined> {
const mutation = gql`
mutation UpdatePage($input: UpdatePageInput!) {
updatePage(input: $input) {
... on UpdatePageSuccess {
updatedPage {
id
title
url
createdAt
author
image
description
savedAt
publishedAt
}
}
... on UpdatePageError {
errorCodes
}
}
}
`
try {
const data = await gqlFetcher(mutation, {
input,
})
const output = data as any
return output.updatePage
} catch (err) {
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,94 +1,96 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { LibraryItemsData } from './useGetLibraryItemsQuery'
// import { gqlFetcher } from '../networkHelpers'
// import { LibraryItems } from '../library_items/useLibraryItems'
export type LibraryItemsQueryInput = {
limit?: number
searchQuery?: string
includeContent?: boolean
const foo = () => {
return 'bar'
}
// export type LibraryItemsQueryInput = {
// limit?: number
// searchQuery?: string
// includeContent?: boolean
// }
export async function searchQuery({
limit = 10,
searchQuery,
includeContent = false,
}: LibraryItemsQueryInput): Promise<LibraryItemsData | undefined> {
const query = gql`
query Search(
$after: String
$first: Int
$query: String
$includeContent: Boolean
) {
search(
first: $first
after: $after
query: $query
includeContent: $includeContent
) {
... on SearchSuccess {
edges {
cursor
node {
id
title
slug
url
pageType
contentReader
createdAt
isArchived
readingProgressPercent
readingProgressTopPercent
readingProgressAnchorIndex
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
id
name
color
}
pageId
shortId
quote
annotation
state
siteName
subscription
readAt
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on SearchError {
errorCodes
}
}
}
`
// export async function searchQuery({
// limit = 10,
// searchQuery,
// includeContent = false,
// }: LibraryItemsQueryInput): Promise<LibraryItemsData | undefined> {
// const query = gql`
// query Search(
// $after: String
// $first: Int
// $query: String
// $includeContent: Boolean
// ) {
// search(
// first: $first
// after: $after
// query: $query
// includeContent: $includeContent
// ) {
// ... on SearchSuccess {
// edges {
// cursor
// node {
// id
// title
// slug
// url
// pageType
// contentReader
// createdAt
// readingProgressPercent
// readingProgressTopPercent
// readingProgressAnchorIndex
// author
// image
// description
// publishedAt
// ownedByViewer
// originalArticleUrl
// uploadFileId
// labels {
// id
// name
// color
// }
// pageId
// shortId
// quote
// annotation
// state
// siteName
// subscription
// readAt
// }
// }
// pageInfo {
// hasNextPage
// hasPreviousPage
// startCursor
// endCursor
// totalCount
// }
// }
// ... on SearchError {
// errorCodes
// }
// }
// }
// `
const variables = {
first: limit,
query: searchQuery,
includeContent,
}
// const variables = {
// first: limit,
// query: searchQuery,
// includeContent,
// }
try {
const data = await gqlFetcher(query, { ...variables })
return (data as LibraryItemsData) || undefined
} catch (error) {
console.log('search error', error)
return undefined
}
}
// try {
// const data = await gqlFetcher(query, { ...variables })
// return (data as LibraryItemsData) || undefined
// } catch (error) {
// console.log('search error', error)
// return undefined
// }
// }

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

@ -10,11 +10,12 @@ import { Highlight, highlightFragment } from '../fragments/highlightFragment'
import { ScopedMutator } from 'swr/dist/_internal'
import { Label, labelFragment } from '../fragments/labelFragment'
import {
ArticleAttributes,
LibraryItems,
Recommendation,
recommendationFragment,
} from './useGetLibraryItemsQuery'
} from '../library_items/useLibraryItems'
import useSWR from 'swr'
import { recommendationFragment } from '../library_items/gql'
type ArticleQueryInput = {
username?: string
@ -39,37 +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
isArchived: boolean
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

@ -2,7 +2,7 @@ import { gql } from 'graphql-request'
import useSWRInfinite from 'swr/infinite'
import { Highlight, highlightFragment } from '../fragments/highlightFragment'
import { gqlFetcher } from '../networkHelpers'
import { PageInfo } from './useGetLibraryItemsQuery'
import { PageInfo } from '../library_items/useLibraryItems'
interface HighlightsResponse {
data?: Array<HighlightsData>

View file

@ -1,6 +1,6 @@
import { gql } from 'graphql-request'
import useSWR from 'swr'
import { gqlFetcher, makeGqlFetcher, publicGqlFetcher } from '../networkHelpers'
import { makeGqlFetcher } from '../networkHelpers'
type HomeResult = {
home: {

View file

@ -1,66 +0,0 @@
import { gql } from 'graphql-request'
import useSWR from 'swr'
import { Label, labelFragment } from '../fragments/labelFragment'
import { publicGqlFetcher } from '../networkHelpers'
type LabelsQueryResponse = {
error: any
isLoading: boolean
isValidating: boolean
labels: Label[]
revalidate: () => void
}
type LabelsResponseData = {
labels?: LabelsData
}
type LabelsData = {
labels?: unknown
}
export function useGetLabelsQuery(): LabelsQueryResponse {
const query = gql`
query GetLabels {
labels {
... on LabelsSuccess {
labels {
...LabelFields
}
}
... on LabelsError {
errorCodes
}
}
}
${labelFragment}
`
const { data, error, mutate, isValidating } = useSWR(query, publicGqlFetcher)
try {
if (data && !error) {
const result = data as LabelsResponseData
const labels = result.labels?.labels as Label[]
return {
error,
isLoading: !error && !data,
isValidating,
labels,
revalidate: () => {
mutate()
},
}
}
} catch (error) {
console.log('error', error)
}
return {
error,
isLoading: !error && !data,
isValidating: false,
labels: [],
// eslint-disable-next-line @typescript-eslint/no-empty-function
revalidate: () => {},
}
}

View file

@ -1,582 +0,0 @@
import { gql } from 'graphql-request'
import useSWRInfinite from 'swr/infinite'
import {
showErrorToast,
showSuccessToast,
showSuccessToastWithUndo,
} from '../../toastHelpers'
import { ContentReader, PageType, State } from '../fragments/articleFragment'
import { Highlight, highlightFragment } from '../fragments/highlightFragment'
import { articleReadingProgressMutation } from '../mutations/articleReadingProgressMutation'
import { deleteLinkMutation } from '../mutations/deleteLinkMutation'
import { setLinkArchivedMutation } from '../mutations/setLinkArchivedMutation'
import { updatePageMutation } from '../mutations/updatePageMutation'
import { gqlFetcher, makeGqlFetcher } from '../networkHelpers'
import { Label } from './../fragments/labelFragment'
import { moveToFolderMutation } from '../mutations/moveToLibraryMutation'
import useSWR from 'swr'
export interface ReadableItem {
id: string
title: string
slug: string
}
export type LibraryItemsQueryInput = {
limit: number
sortDescending: boolean
searchQuery?: string
cursor?: string
includeContent?: boolean
}
type LibraryItemsQueryResponse = {
itemsPages?: LibraryItemsData[]
itemsDataError?: unknown
isLoading: boolean
isValidating: boolean
error: boolean
size: number
setSize: (
size: number | ((_size: number) => number)
) => Promise<unknown[] | undefined>
performActionOnItem: (action: LibraryItemAction, item: LibraryItem) => void
mutate: () => void
}
type LibraryItemsRawQueryResponse = {
items: LibraryItemNode[]
itemsDataError?: unknown
isLoading: boolean
isValidating: boolean
error: boolean
}
type LibraryItemAction =
| 'archive'
| 'unarchive'
| 'delete'
| 'mark-read'
| 'mark-unread'
| 'refresh'
| 'unsubscribe'
| 'update-item'
| 'move-to-inbox'
export type LibraryItemsData = {
search: LibraryItems
errorCodes?: string[]
}
export type LibraryItems = {
edges: LibraryItem[]
pageInfo: PageInfo
errorCodes?: string[]
}
export type LibraryItem = {
cursor: string
node: LibraryItemNode
isLoading?: boolean | undefined
}
export type LibraryItemNode = {
id: string
title: string
url: string
author?: string
image?: string
createdAt: string
publishedAt?: string
contentReader?: ContentReader
originalArticleUrl: string
readingProgressPercent: number
readingProgressTopPercent?: number
readingProgressAnchorIndex: number
slug: string
folder?: string
isArchived: boolean
description: string
ownedByViewer: boolean
uploadFileId: string
labels?: Label[]
pageId: string
shortId: string
quote: string
annotation: string
state: State
pageType: PageType
siteName?: string
siteIcon?: string
subscription?: string
readAt?: string
savedAt?: string
wordsCount?: number
aiSummary?: string
recommendations?: Recommendation[]
highlights?: Highlight[]
}
export type Recommendation = {
id: string
name: string
note?: string
user?: RecommendingUser
recommendedAt: Date
}
export type RecommendingUser = {
userId: string
name: string
username: string
profileImageURL?: string
}
export type PageInfo = {
hasNextPage: boolean
hasPreviousPage: boolean
startCursor: string
endCursor: string
totalCount: number
}
export const recommendationFragment = gql`
fragment RecommendationFields on Recommendation {
id
name
note
user {
userId
name
username
profileImageURL
}
recommendedAt
}
`
export function useGetLibraryItemsQuery(
folder: string,
{ limit, searchQuery, cursor, includeContent = false }: LibraryItemsQueryInput
): LibraryItemsQueryResponse {
const fullQuery = (`in:${folder} use:folders ` + (searchQuery ?? '')).trim()
const query = gql`
query Search(
$after: String
$first: Int
$query: String
$includeContent: Boolean
) {
search(
first: $first
after: $after
query: $query
includeContent: $includeContent
) {
... on SearchSuccess {
edges {
cursor
node {
id
title
slug
url
folder
pageType
contentReader
createdAt
isArchived
readingProgressPercent
readingProgressTopPercent
readingProgressAnchorIndex
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
id
name
color
}
pageId
shortId
quote
annotation
state
siteName
siteIcon
subscription
readAt
savedAt
wordsCount
recommendations {
id
name
note
user {
userId
name
username
profileImageURL
}
recommendedAt
}
highlights {
...HighlightFields
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on SearchError {
errorCodes
}
}
}
${highlightFragment}
`
const variables = {
after: cursor,
first: limit,
query: fullQuery,
includeContent,
}
const { data, error, mutate, size, setSize, isValidating } = useSWRInfinite(
(pageIndex, previousPageData) => {
const key = [query, variables.first, variables.query, undefined]
const previousResult = previousPageData as LibraryItemsData
if (pageIndex === 0) {
return key
}
return [
query,
limit,
searchQuery,
pageIndex === 0 ? undefined : previousResult.search.pageInfo.endCursor,
]
},
(args: any[]) => {
const pageIndex = args[3] as number
return gqlFetcher(query, { ...variables, after: pageIndex }, true)
},
{ revalidateFirstPage: false }
)
let responseError = error
let responsePages = data as LibraryItemsData[] | 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.search.errorCodes && d.search.errorCodes.length > 0
)
if (errors?.length > 0) {
responseError = errors
responsePages = undefined
}
}
const getIndexOf = (page: LibraryItems, item: LibraryItem) => {
return page.edges.findIndex((i) => i.node.id === item.node.id)
}
const performActionOnItem = async (
action: LibraryItemAction,
item: LibraryItem
) => {
console.log('performing action on items: ', action)
if (!responsePages) {
return
}
const updateData = (mutatedItem: LibraryItem | undefined) => {
if (!responsePages) {
return
}
for (const searchResults of responsePages) {
const itemIndex = getIndexOf(searchResults.search, item)
if (itemIndex !== -1) {
if (typeof mutatedItem === 'undefined') {
searchResults.search.edges.splice(itemIndex, 1)
} else {
searchResults.search.edges.splice(itemIndex, 1, mutatedItem)
}
break
}
}
mutate(responsePages, false)
}
switch (action) {
case 'move-to-inbox':
updateData({
cursor: item.cursor,
node: {
...item.node,
folder: 'inbox',
},
})
moveToFolderMutation(item.cursor, 'inbox').then((res) => {
if (res) {
showSuccessToast('Link moved', { position: 'bottom-right' })
} else {
showErrorToast('Error moving link', { position: 'bottom-right' })
}
})
mutate()
break
case 'archive':
updateData({
cursor: item.cursor,
node: {
...item.node,
isArchived: true,
},
})
setLinkArchivedMutation({
linkId: item.node.id,
archived: true,
}).then((res) => {
if (res) {
showSuccessToast('Link archived', { position: 'bottom-right' })
} else {
showErrorToast('Error archiving link', { position: 'bottom-right' })
}
})
mutate()
break
case 'unarchive':
updateData({
cursor: item.cursor,
node: {
...item.node,
isArchived: false,
},
})
setLinkArchivedMutation({
linkId: item.node.id,
archived: false,
}).then((res) => {
if (res) {
showSuccessToast('Link unarchived', { position: 'bottom-right' })
} else {
showErrorToast('Error unarchiving link', {
position: 'bottom-right',
})
}
})
mutate()
break
case 'delete':
updateData({
cursor: item.cursor,
node: {
...item.node,
state: State.DELETED,
},
})
const pageId = item.node.id
deleteLinkMutation(pageId).then((res) => {
if (res) {
showSuccessToastWithUndo('Page deleted', async () => {
const result = await updatePageMutation({
pageId: pageId,
state: State.SUCCEEDED,
})
mutate()
if (result) {
showSuccessToast('Page recovered')
} else {
showErrorToast(
'Error recovering page, check your deleted items'
)
}
})
} else {
showErrorToast('Error removing link', { position: 'bottom-right' })
}
})
break
case 'mark-read':
updateData({
cursor: item.cursor,
node: {
...item.node,
readingProgressPercent: 100,
readingProgressTopPercent: 100,
},
})
articleReadingProgressMutation({
id: item.node.id,
force: true,
readingProgressPercent: 100,
readingProgressTopPercent: 100,
readingProgressAnchorIndex: 0,
})
mutate()
break
case 'mark-unread':
updateData({
cursor: item.cursor,
node: {
...item.node,
readingProgressPercent: 0,
readingProgressTopPercent: 0,
readingProgressAnchorIndex: 0,
},
})
articleReadingProgressMutation({
id: item.node.id,
force: true,
readingProgressPercent: 0,
readingProgressTopPercent: 0,
readingProgressAnchorIndex: 0,
})
mutate()
break
case 'update-item':
updateData(item)
mutate()
break
case 'refresh':
await mutate()
}
}
return {
isValidating,
itemsPages: responsePages || undefined,
itemsDataError: responseError,
isLoading: !error && !data,
performActionOnItem,
size,
setSize,
mutate,
error: !!error,
}
}
export function useGetRawSearchItemsQuery(
{
limit,
searchQuery,
cursor,
includeContent = false,
}: LibraryItemsQueryInput,
shouldFetch = true
): LibraryItemsRawQueryResponse {
const query = gql`
query Search(
$after: String
$first: Int
$query: String
$includeContent: Boolean
) {
search(
first: $first
after: $after
query: $query
includeContent: $includeContent
) {
... on SearchSuccess {
edges {
cursor
node {
id
title
slug
url
folder
createdAt
author
image
description
publishedAt
originalArticleUrl
siteName
siteIcon
subscription
readAt
savedAt
wordsCount
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on SearchError {
errorCodes
}
}
}
`
const variables = {
after: cursor,
first: limit,
query: searchQuery,
includeContent,
}
const { data, error, isValidating, mutate } = useSWR(
shouldFetch ? [query, variables.first, variables.after] : null,
makeGqlFetcher(query, variables),
{
revalidateIfStale: false,
revalidateOnFocus: false,
}
)
const responseError = error
const responseData = data as LibraryItemsData | 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 (responseData?.errorCodes) {
return {
isValidating: false,
items: [],
isLoading: false,
error: true,
}
}
return {
isValidating,
items: responseData?.search.edges.map((edge) => edge.node) ?? [],
itemsDataError: responseError,
isLoading: !error && !data,
error: !!error,
}
}

View file

@ -1,56 +0,0 @@
import { gql } from 'graphql-request'
import useSWR from 'swr'
import { publicGqlFetcher } from '../networkHelpers'
import {
SavedSearch,
savedSearchFragment,
} from '../fragments/savedSearchFragment'
type SavedSearchResponse = {
error: any
savedSearches?: SavedSearch[]
savedSearchErrors?: unknown
isLoading: boolean
}
type SavedSearchResponseData = {
filters: { filters: SavedSearch[] }
}
export function useGetSavedSearchQuery(): SavedSearchResponse {
const query = gql`
query SavedSearches {
filters {
... on FiltersSuccess {
filters {
...FiltersFragment
}
}
... on FiltersError {
errorCodes
}
}
}
${savedSearchFragment}
`
const { data, error } = useSWR(query, publicGqlFetcher)
if (data) {
const { filters } = data as SavedSearchResponseData
return {
error,
savedSearches: filters?.filters ?? [],
savedSearchErrors: error ?? {},
isLoading: false,
}
}
return {
error,
savedSearches: [],
savedSearchErrors: null,
isLoading: !error && !data,
}
}

View file

@ -0,0 +1,68 @@
import { gql } from 'graphql-request'
import { savedSearchFragment } from '../fragments/savedSearchFragment'
export const GQL_GET_SAVED_SEARCHES = gql`
query SavedSearches {
filters {
... on FiltersSuccess {
filters {
...FiltersFragment
}
}
... on FiltersError {
errorCodes
}
}
}
${savedSearchFragment}
`
export const GQL_DELETE_SAVED_SEARCH = gql`
mutation DeleteFilter($id: ID!) {
deleteFilter(id: $id) {
... on DeleteFilterSuccess {
filter {
...FiltersFragment
}
}
... on DeleteFilterError {
errorCodes
}
}
}
${savedSearchFragment}
`
export const GQL_CREATE_SAVED_SEARCH = gql`
mutation SaveFilter($input: SaveFilterInput!) {
saveFilter(input: $input) {
... on SaveFilterSuccess {
filter {
...FiltersFragment
}
}
... on SaveFilterError {
errorCodes
}
}
}
${savedSearchFragment}
`
export const GQL_UPDATE_SAVED_SEARCH = gql`
mutation UpdateFilter($input: UpdateFilterInput!) {
updateFilter(input: $input) {
... on UpdateFilterSuccess {
filter {
...FiltersFragment
}
}
... on UpdateFilterError {
errorCodes
}
}
}
${savedSearchFragment}
`

View file

@ -0,0 +1,173 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { gqlFetcher } from '../networkHelpers'
import {
GQL_CREATE_SAVED_SEARCH,
GQL_DELETE_SAVED_SEARCH,
GQL_GET_SAVED_SEARCHES,
GQL_UPDATE_SAVED_SEARCH,
} from './gql'
import { SavedSearch } from '../fragments/savedSearchFragment'
export function useGetSavedSearches() {
return useQuery({
queryKey: ['filters'],
queryFn: async () => {
const response = (await gqlFetcher(
GQL_GET_SAVED_SEARCHES
)) as SavedSearchData
if (response.filters?.errorCodes?.length) {
throw new Error(response.filters.errorCodes[0])
}
return response.filters.filters
},
})
}
export const useCreateSavedSearch = () => {
const queryClient = useQueryClient()
const createSavedSearch = async (variables: {
name: string
filter: string
category: string
position: number
}) => {
const result = (await gqlFetcher(GQL_CREATE_SAVED_SEARCH, {
input: {
name: variables.name,
filter: variables.filter,
category: variables.category,
position: variables.position,
},
})) as CreateSavedSearchData
if (result.saveFilter.errorCodes?.length) {
throw new Error(result.saveFilter.errorCodes[0])
}
return result.saveFilter?.filter
}
return useMutation({
mutationFn: createSavedSearch,
onSuccess: (newSavedSearch) => {
const keys = queryClient
.getQueryCache()
.findAll({ queryKey: ['filters'] })
keys.forEach((query) => {
queryClient.setQueryData(query.queryKey, (data: SavedSearch[]) => {
return [...data, newSavedSearch]
})
})
},
})
}
export const useUpdateSavedSearch = () => {
const queryClient = useQueryClient()
const updateSavedSearch = async (variables: {
input: UpdateSavedSearchInput
}) => {
const result = (await gqlFetcher(GQL_UPDATE_SAVED_SEARCH, {
input: {
id: variables.input.id,
name: variables.input.name,
visible: variables.input.visible,
filter: variables.input.filter,
position: variables.input.position,
},
})) as UpdateSavedSearchData
if (result.updateFilter.errorCodes?.length) {
throw new Error(result.updateFilter.errorCodes[0])
}
return result.updateFilter?.filter
}
return useMutation({
mutationFn: updateSavedSearch,
onSuccess: (updatedSavedSearch) => {
if (updatedSavedSearch) {
const keys = queryClient
.getQueryCache()
.findAll({ queryKey: ['filters'] })
keys.forEach((query) => {
queryClient.setQueryData(query.queryKey, (data: SavedSearch[]) => {
return [
...data.filter(
(savedSearch) => savedSearch.id !== updatedSavedSearch.id
),
updatedSavedSearch,
]
})
})
}
},
})
}
export const useDeleteSavedSearch = () => {
const queryClient = useQueryClient()
const deleteSavedSearch = async (variables: { searchId: string }) => {
const result = (await gqlFetcher(GQL_DELETE_SAVED_SEARCH, {
id: variables.searchId,
})) as DeleteSavedSearchData
if (result.deleteFilter.errorCodes?.length) {
throw new Error(result.deleteFilter.errorCodes[0])
}
return result.deleteFilter?.filter?.id
}
return useMutation({
mutationFn: deleteSavedSearch,
onSuccess: (deletedId) => {
if (deletedId) {
const keys = queryClient
.getQueryCache()
.findAll({ queryKey: ['filters'] })
keys.forEach((query) => {
queryClient.setQueryData(query.queryKey, (data: SavedSearch[]) => {
return data.filter((filter) => filter.id !== deletedId)
})
})
}
},
})
}
type UpdateSavedSearchResult = {
filter?: SavedSearch
errorCodes?: string[]
}
type UpdateSavedSearchData = {
updateFilter: UpdateSavedSearchResult
}
type CreateSavedSearchResult = {
filter?: SavedSearch
errorCodes?: string[]
}
type CreateSavedSearchData = {
saveFilter: CreateSavedSearchResult
}
type DeleteSavedSearchResult = {
filter?: SavedSearch
errorCodes?: string[]
}
type DeleteSavedSearchData = {
deleteFilter: DeleteSavedSearchResult
}
type FiltersResult = {
filters?: SavedSearch[]
errorCodes?: string[]
}
type SavedSearchData = {
filters: FiltersResult
}
export type UpdateSavedSearchInput = {
id?: string
name?: string
filter?: string
position?: number
category?: string
description?: string
visible?: boolean
folder?: string
}

View file

@ -0,0 +1,135 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestHeaders } from '../networkHelpers'
import { fetchEndpoint } from '../../appConfig'
import { Label } from '../fragments/labelFragment'
export type ShortcutType = 'search' | 'label' | 'newsletter' | 'feed' | 'folder'
export type Shortcut = {
type: ShortcutType
id: string
name: string
section: string
filter: string
icon?: string
label?: Label
join?: string
children?: Shortcut[]
}
export function useGetShortcuts() {
return useQuery({
queryKey: ['shortcuts'],
queryFn: async () => {
return await getShortcuts()
},
})
}
export const useSetShortcuts = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables: { shortcuts: Shortcut[] }) => {
return await setShortcuts(variables)
},
onMutate: async (variables: { shortcuts: Shortcut[] }) => {
await queryClient.cancelQueries({ queryKey: ['shortcuts'] })
queryClient.setQueryData(['shortcuts'], variables.shortcuts)
const previousState = {
previousItems: queryClient.getQueryData(['shortcuts']),
}
return previousState
},
onError: (error, variables, context) => {
if (context?.previousItems) {
queryClient.setQueryData(['shortcuts'], context.previousItems)
}
},
})
}
export const useResetShortcuts = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
return await resetShortcuts()
},
onMutate: async () => {
const previousState = {
previousItems: queryClient.getQueryData(['shortcuts']),
}
return previousState
},
onError: (error, variables, context) => {
if (context?.previousItems) {
queryClient.setQueryData(['shortcuts'], context.previousItems)
}
},
onSuccess: (data, variables, context) => {
queryClient.setQueryData(['shortcuts'], data)
},
})
}
async function getShortcuts(): Promise<Shortcut[]> {
const url = new URL(`/api/shortcuts`, fetchEndpoint)
try {
const response = await fetch(url.toString(), {
method: 'GET',
headers: requestHeaders(),
credentials: 'include',
mode: 'cors',
})
const payload = await response.json()
if ('shortcuts' in payload) {
return payload['shortcuts'] as Shortcut[]
}
return []
} catch (err) {
console.log('error getting shortcuts: ', err)
throw err
}
}
async function setShortcuts(variables: {
shortcuts: Shortcut[]
}): Promise<Shortcut[]> {
const url = new URL(`/api/shortcuts`, fetchEndpoint)
const response = await fetch(url.toString(), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...requestHeaders(),
},
credentials: 'include',
mode: 'cors',
body: JSON.stringify({ shortcuts: variables.shortcuts }),
})
const payload = await response.json()
if (!('shortcuts' in payload)) {
throw new Error('Error syncing shortcuts')
}
return payload['shortcuts'] as Shortcut[]
}
async function resetShortcuts(): Promise<Shortcut[]> {
const url = new URL(`/api/shortcuts`, fetchEndpoint)
const response = await fetch(url.toString(), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...requestHeaders(),
},
credentials: 'include',
mode: 'cors',
})
const payload = await response.json()
if (!('shortcuts' in payload)) {
throw new Error('Error syncing shortcuts')
}
return payload['shortcuts'] as Shortcut[]
}

View file

@ -114,6 +114,8 @@ const showToastWithAction = (
action: () => Promise<void>,
options?: ToastOptions
) => {
console.trace('show success: ', message)
return toast(
({ id }) => (
<FullWidthContainer alignment="center">
@ -124,7 +126,6 @@ const showToastWithAction = (
style="ctaLightGray"
onClick={(event) => {
event.preventDefault()
toast.dismiss(id)
;(async () => {
await action()

View file

@ -49,6 +49,34 @@ const moduleExports = {
source: '/collect/:match*',
destination: 'https://app.posthog.com/:match*',
})
rewrites.push({
source: '/home',
destination: '/l/home',
})
rewrites.push({
source: '/library',
destination: '/l/library',
})
rewrites.push({
source: '/subscriptions',
destination: '/l/subscriptions',
})
rewrites.push({
source: '/highlights',
destination: '/l/highlights',
})
rewrites.push({
source: '/subscriptions',
destination: '/l/subscriptions',
})
rewrites.push({
source: '/archive',
destination: '/l/archive',
})
rewrites.push({
source: '/trash',
destination: '/l/trash',
})
return rewrites
},
async headers() {

View file

@ -32,6 +32,7 @@
"@radix-ui/react-switch": "^1.0.1",
"@sentry/nextjs": "^7.42.0",
"@stitches/react": "^1.2.5",
"@tanstack/react-query": "^5.51.21",
"allotment": "^1.20.2",
"antd": "4.24.3",
"axios": "^1.2.0",
@ -108,4 +109,4 @@
"volta": {
"extends": "../../package.json"
}
}
}

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 })
@ -57,8 +54,6 @@ export default function Debug(): JSX.Element {
result.push({ name: 'savedAt', value: article.savedAt })
result.push({ name: 'createdAt', value: article.createdAt })
result.push({ name: 'publishedAt', value: article.publishedAt ?? 'null' })
result.push({ name: 'isArchived', value: article.isArchived.toString() })
result.push({ name: 'description', value: article.description ?? 'null' })
result.push({
@ -173,7 +168,7 @@ export default function Debug(): JSX.Element {
// recommendations?: Recommendation[]
return result
}, [articleData])
}, [article])
return (
<>

View file

@ -1,10 +1,6 @@
import { PrimaryLayout } from '../../../components/templates/PrimaryLayout'
import { LoadingView } from '../../../components/patterns/LoadingView'
import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery'
import {
removeItemFromCache,
useGetArticleQuery,
} from '../../../lib/networking/queries/useGetArticleQuery'
import { useRouter } from 'next/router'
import { VStack } from './../../../components/elements/LayoutPrimitives'
import {
@ -15,37 +11,37 @@ import { PdfArticleContainerProps } from './../../../components/templates/articl
import { useCallback, useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import { Toaster } from 'react-hot-toast'
import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation'
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeHighlightMutation'
import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation'
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
import Script from 'next/script'
import { ArticleActionsMenu } from '../../../components/templates/article/ArticleActionsMenu'
import { setLinkArchivedMutation } from '../../../lib/networking/mutations/setLinkArchivedMutation'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { useSWRConfig } from 'swr'
import {
showErrorToast,
showSuccessToast,
showSuccessToastWithUndo,
} from '../../../lib/toastHelpers'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { SetLabelsModal } from '../../../components/templates/article/SetLabelsModal'
import { DisplaySettingsModal } from '../../../components/templates/article/DisplaySettingsModal'
import { useReaderSettings } from '../../../lib/hooks/useReaderSettings'
import { SkeletonArticleContainer } from '../../../components/templates/article/SkeletonArticleContainer'
import { useRegisterActions } from 'kbar'
import { deleteLinkMutation } from '../../../lib/networking/mutations/deleteLinkMutation'
import { ReaderHeader } from '../../../components/templates/reader/ReaderHeader'
import { EditArticleModal } from '../../../components/templates/homeFeed/EditItemModals'
import { VerticalArticleActionsMenu } from '../../../components/templates/article/VerticalArticleActions'
import { PdfHeaderSpacer } from '../../../components/templates/article/PdfHeaderSpacer'
import { EpubContainerProps } from '../../../components/templates/article/EpubContainer'
import { useSetPageLabels } from '../../../lib/hooks/useSetPageLabels'
import { updatePageMutation } from '../../../lib/networking/mutations/updatePageMutation'
import { State } from '../../../lib/networking/fragments/articleFragment'
import { posthog } from 'posthog-js'
import { PDFDisplaySettingsModal } from '../../../components/templates/article/PDFDisplaySettingsModal'
import {
ArticleReadingProgressMutationInput,
useArchiveItem,
useDeleteItem,
useGetLibraryItemContent,
useUpdateItemReadStatus,
} from '../../../lib/networking/library_items/useLibraryItems'
import {
CreateHighlightInput,
useCreateHighlight,
useDeleteHighlight,
useMergeHighlight,
useUpdateHighlight,
} from '../../../lib/networking/highlights/useItemHighlights'
const PdfArticleContainerNoSSR = dynamic<PdfArticleContainerProps>(
() => import('./../../../components/templates/article/PdfArticleContainer'),
@ -57,26 +53,31 @@ const EpubContainerNoSSR = dynamic<EpubContainerProps>(
{ ssr: false }
)
export default function Home(): JSX.Element {
export default function Reader(): JSX.Element {
const router = useRouter()
const { cache, mutate } = useSWRConfig()
const [showEditModal, setShowEditModal] = useState(false)
const [showHighlightsModal, setShowHighlightsModal] = useState(false)
const { viewerData } = useGetViewerQuery()
const readerSettings = useReaderSettings()
const archiveItem = useArchiveItem()
const deleteItem = useDeleteItem()
const updateItemReadStatus = useUpdateItemReadStatus()
const createHighlight = useCreateHighlight()
const deleteHighlight = useDeleteHighlight()
const updateHighlight = useUpdateHighlight()
const mergeHighlight = useMergeHighlight()
const { articleData, articleFetchError } = useGetArticleQuery({
username: router.query.username as string,
slug: router.query.slug as string,
includeFriendsHighlights: false,
})
const article = articleData?.article.article
const { data: libraryItem, error: articleFetchError } =
useGetLibraryItemContent(
router.query.username as string,
router.query.slug as string
)
useEffect(() => {
dispatchLabels({
type: 'RESET',
labels: article?.labels ?? [],
labels: libraryItem?.labels ?? [],
})
}, [articleData?.article.article])
}, [libraryItem])
const goNextOrHome = useCallback(() => {
// const listStr = localStorage.getItem('library-slug-list')
@ -97,7 +98,7 @@ export default function Home(): JSX.Element {
const query = window.sessionStorage.getItem('q')
router.push(`/home?${query}`)
}, [router, viewerData, article])
}, [router, viewerData, libraryItem])
const goPreviousOrHome = useCallback(() => {
// const listStr = localStorage.getItem('library-slug-list')
@ -113,78 +114,88 @@ export default function Home(): JSX.Element {
const query = window.sessionStorage.getItem('q')
router.push(`/home?${query}`)
// router.push(`/home`)
}, [router, viewerData, article])
}, [router, viewerData, libraryItem])
const actionHandler = useCallback(
async (action: string, arg?: unknown) => {
if (!libraryItem) {
return
}
switch (action) {
case 'unarchive':
if (article) {
removeItemFromCache(cache, mutate, article.id)
setLinkArchivedMutation({
linkId: article.id,
archived: false,
}).then((res) => {
if (res) {
showSuccessToast('Link unarchived', {
position: 'bottom-right',
})
} else {
showErrorToast('Error unarchiving link', {
position: 'bottom-right',
})
}
})
goNextOrHome()
}
break
case 'archive':
if (article) {
removeItemFromCache(cache, mutate, article.id)
await setLinkArchivedMutation({
linkId: article.id,
archived: true,
}).then((res) => {
if (!res) {
showErrorToast('Error archiving', {
position: 'bottom-right',
})
} else {
goNextOrHome()
showSuccessToast('Page archived', {
position: 'bottom-right',
})
}
case 'unarchive':
try {
await archiveItem.mutateAsync({
itemId: libraryItem.id,
slug: libraryItem.slug,
input: {
linkId: libraryItem.id,
archived: action == 'archive',
},
})
} catch {
showErrorToast(`Error ${action}ing item`, {
position: 'bottom-right',
})
return
}
showSuccessToast(`Item ${action}d`, {
position: 'bottom-right',
})
goNextOrHome()
break
case 'mark-read':
if (article) {
articleReadingProgressMutation({
id: article.id,
force: true,
readingProgressPercent: 100,
readingProgressTopPercent: 100,
readingProgressAnchorIndex: 0,
}).then((res) => {
if (!res) {
// todo: revalidate or put back in cache?
showErrorToast('Error marking as read', {
position: 'bottom-right',
})
} else {
goNextOrHome()
}
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({
itemId: libraryItem.id,
slug: libraryItem.slug,
input: {
id: libraryItem.id,
force: true,
...values,
},
})
} catch {
showErrorToast(`Error marking as ${desc}`, {
position: 'bottom-right',
})
return
}
goNextOrHome()
break
case 'delete':
await deleteCurrentItem()
try {
await deleteItem.mutateAsync({
itemId: libraryItem.id,
slug: libraryItem.slug,
})
} catch {
showErrorToast(`Error deleting item`, {
position: 'bottom-right',
})
return
}
showSuccessToast(`Item deleted`, {
position: 'bottom-right',
})
goNextOrHome()
break
case 'openOriginalArticle':
const url = article?.url
const url = libraryItem?.url
if (url) {
window.open(url, '_blank')
}
@ -206,7 +217,15 @@ export default function Home(): JSX.Element {
break
}
},
[article, viewerData, cache, mutate, router, readerSettings]
[
libraryItem,
viewerData,
router,
readerSettings,
archiveItem,
deleteItem,
updateItemReadStatus,
]
)
useEffect(() => {
@ -224,6 +243,10 @@ export default function Home(): JSX.Element {
actionHandler('mark-read')
}
const markUnread = () => {
actionHandler('mark-unread')
}
const showEditModal = () => {
actionHandler('showEditModal')
}
@ -231,6 +254,7 @@ export default function Home(): JSX.Element {
document.addEventListener('archive', archive)
document.addEventListener('delete', deletePage)
document.addEventListener('mark-read', markRead)
document.addEventListener('mark-unread', markUnread)
document.addEventListener('openOriginalArticle', openOriginalArticle)
document.addEventListener('showEditModal', showEditModal)
@ -240,6 +264,8 @@ export default function Home(): JSX.Element {
return () => {
document.removeEventListener('archive', archive)
document.removeEventListener('mark-read', markRead)
document.removeEventListener('mark-unread', markUnread)
document.removeEventListener('delete', deletePage)
document.removeEventListener('openOriginalArticle', openOriginalArticle)
document.removeEventListener('showEditModal', showEditModal)
@ -249,42 +275,15 @@ export default function Home(): JSX.Element {
}, [actionHandler, goNextOrHome, goPreviousOrHome])
useEffect(() => {
if (article && viewerData?.me) {
if (libraryItem && viewerData?.me) {
posthog.capture('link_read', {
link: article.id,
slug: article.slug,
reader: article.contentReader,
url: article.originalArticleUrl,
link: libraryItem.id,
slug: libraryItem.slug,
reader: libraryItem.contentReader,
url: libraryItem.originalArticleUrl,
})
}
}, [article, viewerData])
const deleteCurrentItem = useCallback(async () => {
if (article) {
const pageId = article.id
removeItemFromCache(cache, mutate, pageId)
await deleteLinkMutation(pageId).then((res) => {
if (res) {
showSuccessToastWithUndo('Page deleted', async () => {
const result = await updatePageMutation({
pageId: pageId,
state: State.SUCCEEDED,
})
document.dispatchEvent(new Event('revalidateLibrary'))
if (result) {
showSuccessToast('Page recovered')
} else {
showErrorToast('Error recovering page, check your deleted items')
}
})
} else {
// todo: revalidate or put back in cache?
showErrorToast('Error deleting page', { position: 'bottom-right' })
}
})
goNextOrHome()
}
}, [article, cache, mutate, router])
}, [libraryItem, viewerData])
useRegisterActions(
[
@ -360,6 +359,15 @@ export default function Home(): JSX.Element {
document.dispatchEvent(new Event('mark-read'))
},
},
{
id: 'mark_unread',
section: 'Article',
name: 'Mark current item as unread',
shortcut: ['-'],
perform: () => {
document.dispatchEvent(new Event('mark-unread'))
},
},
{
id: 'full_screen',
section: 'Article',
@ -457,10 +465,16 @@ export default function Home(): JSX.Element {
)
const [labels, dispatchLabels] = useSetPageLabels(
articleData?.article.article?.id
libraryItem?.id,
libraryItem?.slug
)
if (articleFetchError && articleFetchError.indexOf('NOT_FOUND') > -1) {
new Error()
if (
articleFetchError &&
'message' in articleFetchError &&
articleFetchError['message'] === 'NOT_FOUND'
) {
router.push('/404')
return <LoadingView />
}
@ -470,18 +484,18 @@ export default function Home(): JSX.Element {
pageTestId="home-page-tag"
headerToolbarControl={
<ArticleActionsMenu
article={article}
article={libraryItem}
layout="top"
showReaderDisplaySettings={article?.contentReader != 'PDF'}
showReaderDisplaySettings={libraryItem?.contentReader != 'PDF'}
readerSettings={readerSettings}
articleActionHandler={actionHandler}
/>
}
alwaysDisplayToolbar={article?.contentReader == 'PDF'}
alwaysDisplayToolbar={libraryItem?.contentReader == 'PDF'}
pageMetaDataProps={{
title: article?.title ?? '',
title: libraryItem?.title ?? '',
path: router.pathname,
description: article?.description ?? '',
description: libraryItem?.description ?? '',
}}
>
<Script async src="/static/mathjax/mathJaxConfiguration.js" />
@ -497,17 +511,17 @@ export default function Home(): JSX.Element {
showDisplaySettingsModal={
readerSettings.setShowEditDisplaySettingsModal
}
alwaysDisplayToolbar={article?.contentReader == 'PDF'}
alwaysDisplayToolbar={libraryItem?.contentReader == 'PDF'}
>
<VerticalArticleActionsMenu
article={article}
article={libraryItem}
layout="top"
showReaderDisplaySettings={article?.contentReader != 'PDF'}
showReaderDisplaySettings={libraryItem?.contentReader != 'PDF'}
articleActionHandler={actionHandler}
/>
</ReaderHeader>
{article?.contentReader == 'PDF' && <PdfHeaderSpacer />}
{libraryItem?.contentReader == 'PDF' && <PdfHeaderSpacer />}
<VStack
distribution="between"
@ -523,9 +537,9 @@ export default function Home(): JSX.Element {
},
}}
>
{article?.contentReader !== 'PDF' ? (
{libraryItem?.contentReader !== 'PDF' ? (
<ArticleActionsMenu
article={article}
article={libraryItem}
layout="side"
readerSettings={readerSettings}
showReaderDisplaySettings={true}
@ -533,15 +547,15 @@ export default function Home(): JSX.Element {
/>
) : null}
</VStack>
{article && viewerData?.me && article.contentReader == 'PDF' && (
{libraryItem && viewerData?.me && libraryItem.contentReader == 'PDF' && (
<PdfArticleContainerNoSSR
article={article}
article={libraryItem}
showHighlightsModal={showHighlightsModal}
setShowHighlightsModal={setShowHighlightsModal}
viewer={viewerData.me}
/>
)}
{article && viewerData?.me && article.contentReader == 'WEB' && (
{libraryItem && viewerData?.me && libraryItem.contentReader == 'WEB' && (
<VStack
id="article-wrapper"
alignment="center"
@ -558,10 +572,10 @@ export default function Home(): JSX.Element {
},
}}
>
{article && viewerData?.me ? (
{libraryItem && viewerData?.me ? (
<ArticleContainer
viewer={viewerData.me}
article={article}
article={libraryItem}
isAppleAppEmbed={false}
highlightBarDisabled={false}
fontSize={readerSettings.fontSize}
@ -577,14 +591,80 @@ export default function Home(): JSX.Element {
readerSettings.highlightOnRelease ?? undefined
}
textDirection={
article.directionality ?? readerSettings.textDirection
libraryItem.directionality ?? readerSettings.textDirection
}
articleMutations={{
createHighlightMutation,
deleteHighlightMutation,
mergeHighlightMutation,
updateHighlightMutation,
articleReadingProgressMutation,
createHighlightMutation: async (
input: CreateHighlightInput
) => {
try {
const result = await createHighlight.mutateAsync({
itemId: libraryItem.id,
slug: libraryItem.slug,
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,
slug: libraryItem.slug,
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,
slug: libraryItem.slug,
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,
slug: libraryItem.slug,
input,
})
return result?.id
} catch (err) {
console.log('error updating highlight', err)
return undefined
}
},
articleReadingProgressMutation: async (
input: ArticleReadingProgressMutationInput
) => {
try {
await updateItemReadStatus.mutateAsync({
itemId: libraryItem.id,
slug: libraryItem.slug,
input,
})
} catch {
return false
}
return true
},
}}
/>
) : (
@ -597,7 +677,7 @@ export default function Home(): JSX.Element {
</VStack>
)}
{article && viewerData?.me && article.contentReader == 'EPUB' && (
{libraryItem && viewerData?.me && libraryItem.contentReader == 'EPUB' && (
<VStack
alignment="center"
distribution="start"
@ -610,9 +690,9 @@ export default function Home(): JSX.Element {
paddingTop: '80px',
}}
>
{article && viewerData?.me ? (
{libraryItem && viewerData?.me ? (
<EpubContainerNoSSR
article={article}
article={libraryItem}
showHighlightsModal={showHighlightsModal}
setShowHighlightsModal={setShowHighlightsModal}
viewer={viewerData.me}
@ -627,15 +707,15 @@ export default function Home(): JSX.Element {
</VStack>
)}
{article && readerSettings.showSetLabelsModal && (
{libraryItem && readerSettings.showSetLabelsModal && (
<SetLabelsModal
provider={article}
provider={libraryItem}
selectedLabels={labels.labels}
dispatchLabels={dispatchLabels}
onOpenChange={() => readerSettings.setShowSetLabelsModal(false)}
/>
)}
{article?.contentReader === 'PDF' &&
{libraryItem?.contentReader === 'PDF' &&
readerSettings.showEditDisplaySettingsModal && (
<PDFDisplaySettingsModal
centerX={true}
@ -645,7 +725,7 @@ export default function Home(): JSX.Element {
}}
/>
)}
{article?.contentReader !== 'PDF' &&
{libraryItem?.contentReader !== 'PDF' &&
readerSettings.showEditDisplaySettingsModal && (
<DisplaySettingsModal
centerX={true}
@ -655,20 +735,19 @@ export default function Home(): JSX.Element {
}}
/>
)}
{article && showEditModal && (
{libraryItem && showEditModal && (
<EditArticleModal
article={article}
article={libraryItem}
onOpenChange={() => setShowEditModal(false)}
updateArticle={(title, author, description, savedAt, publishedAt) => {
article.title = title
article.author = author
article.description = description
article.savedAt = savedAt
article.publishedAt = publishedAt
const titleEvent = new Event('updateTitle') as UpdateTitleEvent
titleEvent.title = title
document.dispatchEvent(titleEvent)
// libraryItem.title = title
// libraryItem.author = author
// libraryItem.description = description
// libraryItem.savedAt = savedAt
// libraryItem.publishedAt = publishedAt
// const titleEvent = new Event('updateTitle') as UpdateTitleEvent
// titleEvent.title = title
// document.dispatchEvent(titleEvent)
}}
/>
)}

View file

@ -5,6 +5,7 @@ import type { AppProps } from 'next/app'
import { IdProvider } from '@radix-ui/react-id'
import { NextRouter, useRouter } from 'next/router'
import { ReactNode, useEffect, useState } from 'react'
import { HydrationBoundary } from '@tanstack/react-query'
import TopBarProgress from 'react-topbar-progress-indicator'
import {
KBarProvider,
@ -23,8 +24,10 @@ import { updateTheme } from '../lib/themeUpdater'
import { ThemeId } from '../components/tokens/stitches.config'
import { posthog } from 'posthog-js'
import { GoogleReCaptchaProvider } from '@google-recaptcha/react'
import { SWRConfig } from 'swr'
import { DEFAULT_HOME_PATH } from '../lib/navigations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
const queryClient = new QueryClient()
TopBarProgress.config({
barColors: {
@ -76,6 +79,14 @@ const ConditionalCaptchaProvider = (props: {
return <>{props.children}</>
}
// const queryClient = new QueryClient({
// defaultOptions: {
// queries: {
// gcTime: 1000 * 60 * 60 * 24,
// },
// },
// })
export function OmnivoreApp({ Component, pageProps }: AppProps): JSX.Element {
const router = useRouter()
@ -91,19 +102,21 @@ export function OmnivoreApp({ Component, pageProps }: AppProps): JSX.Element {
return (
<ConditionalCaptchaProvider>
<KBarProvider actions={generateActions(router)}>
<KBarPortal>
<KBarPositioner style={{ zIndex: 100 }}>
<KBarAnimator style={animatorStyle}>
<KBarSearch style={searchStyle} />
<KBarResultsComponents />
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
<IdProvider>
<Component {...pageProps} />
</IdProvider>
</KBarProvider>
<QueryClientProvider client={queryClient}>
<KBarProvider actions={generateActions(router)}>
<KBarPortal>
<KBarPositioner style={{ zIndex: 100 }}>
<KBarAnimator style={animatorStyle}>
<KBarSearch style={searchStyle} />
<KBarResultsComponents />
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
<IdProvider>
<Component {...pageProps} />
</IdProvider>
</KBarProvider>
</QueryClientProvider>
</ConditionalCaptchaProvider>
)
}

Some files were not shown because too many files have changed in this diff Show more