From 5c85257cdbb8d1eaf869125accf260c463771197 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 21 Jun 2024 13:20:27 +0800 Subject: [PATCH] Implement actions on the home view --- .../nav-containers/HighlightsContainer.tsx | 319 ++++++ .../nav-containers/HomeContainer.tsx | 920 ++++++++++++++++++ .../web/components/tokens/stitches.config.ts | 2 +- .../web/lib/hooks/useLibraryItemActions.tsx | 69 ++ .../mutations/moveToLibraryMutation.ts | 41 + 5 files changed, 1350 insertions(+), 1 deletion(-) create mode 100644 packages/web/components/nav-containers/HighlightsContainer.tsx create mode 100644 packages/web/components/nav-containers/HomeContainer.tsx create mode 100644 packages/web/lib/hooks/useLibraryItemActions.tsx create mode 100644 packages/web/lib/networking/mutations/moveToLibraryMutation.ts diff --git a/packages/web/components/nav-containers/HighlightsContainer.tsx b/packages/web/components/nav-containers/HighlightsContainer.tsx new file mode 100644 index 000000000..ef1358249 --- /dev/null +++ b/packages/web/components/nav-containers/HighlightsContainer.tsx @@ -0,0 +1,319 @@ +import { NavigationLayout } from '../templates/NavigationLayout' +import { Box, HStack, VStack } from '../elements/LayoutPrimitives' +import { useFetchMore } from '../../lib/hooks/useFetchMoreScroll' +import { useCallback, useMemo, useState } from 'react' +import { useGetHighlights } from '../../lib/networking/queries/useGetHighlights' +import { Highlight } from '../../lib/networking/fragments/highlightFragment' +import { NextRouter, useRouter } from 'next/router' +import { + UserBasicData, + useGetViewerQuery, +} from '../../lib/networking/queries/useGetViewerQuery' +import { SetHighlightLabelsModalPresenter } from '../templates/article/SetLabelsModalPresenter' +import { TrashIcon } from '../elements/icons/TrashIcon' +import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' +import { ConfirmationModal } from '../patterns/ConfirmationModal' +import { deleteHighlightMutation } from '../../lib/networking/mutations/deleteHighlightMutation' +import { LabelChip } from '../elements/LabelChip' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { timeAgo } from '../patterns/LibraryCards/LibraryCardStyles' +import { HighlightHoverActions } from '../patterns/HighlightHoverActions' +import { + autoUpdate, + offset, + size, + useFloating, + useHover, + useInteractions, +} from '@floating-ui/react' +import { highlightColor } from '../../lib/themeUpdater' + +import { HighlightViewNote } from '../patterns/HighlightNotes' +import { theme } from '../tokens/stitches.config' + +const PAGE_SIZE = 10 + +export function HighlightsContainer(): JSX.Element { + const router = useRouter() + const viewer = useGetViewerQuery() + const [showFilterMenu, setShowFilterMenu] = useState(false) + const [_, setShowAddLinkModal] = useState(false) + + const { isLoading, setSize, size, data, mutate } = useGetHighlights({ + first: PAGE_SIZE, + }) + + const hasMore = useMemo(() => { + if (!data) { + return false + } + return data[data.length - 1].highlights.pageInfo.hasNextPage + }, [data]) + + const handleFetchMore = useCallback(() => { + if (isLoading || !hasMore) { + return + } + setSize(size + 1) + }, [isLoading, hasMore, setSize, size]) + + useFetchMore(handleFetchMore) + + const highlights = useMemo(() => { + if (!data) { + return [] + } + return data.flatMap((res) => res.highlights.edges.map((edge) => edge.node)) + }, [data]) + + return ( + + {highlights.map((highlight) => { + return ( + viewer.viewerData?.me && ( + + ) + ) + })} + + ) +} + +type HighlightCardProps = { + highlight: Highlight + viewer: UserBasicData + router: NextRouter + mutate: () => void +} + +type HighlightAnnotationProps = { + highlight: Highlight +} + +function HighlightAnnotation({ + highlight, +}: HighlightAnnotationProps): JSX.Element { + const [noteMode, setNoteMode] = useState<'edit' | 'preview'>('preview') + const [annotation, setAnnotation] = useState(highlight.annotation) + + return ( + { + setAnnotation(highlight.annotation) + }} + /> + ) +} + +function HighlightCard(props: HighlightCardProps): JSX.Element { + const [isOpen, setIsOpen] = useState(false) + const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] = + useState(undefined) + const [labelsTarget, setLabelsTarget] = useState( + undefined + ) + + const viewInReader = useCallback( + (highlightId: string) => { + const router = props.router + const viewer = props.viewer + const item = props.highlight.libraryItem + + if (!router || !router.isReady || !viewer || !item) { + showErrorToast('Error navigating to highlight') + return + } + + router.push( + { + pathname: '/[username]/[slug]', + query: { + username: viewer.profile.username, + slug: item.slug, + }, + hash: highlightId, + }, + `${viewer.profile.username}/${item.slug}#${highlightId}`, + { + scroll: false, + } + ) + }, + [props.highlight.libraryItem, props.viewer, props.router] + ) + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + middleware: [ + offset({ + mainAxis: -25, + }), + size(), + ], + placement: 'top-end', + whileElementsMounted: autoUpdate, + }) + + const hover = useHover(context) + + const { getReferenceProps, getFloatingProps } = useInteractions([hover]) + + return ( + + + + + + + {timeAgo(props.highlight.updatedAt)} + + {props.highlight.quote && ( + + {props.highlight.quote} + + )} + + {props.highlight.labels && ( + + {props.highlight.labels.map((label) => { + return ( + + ) + })} + + )} + + {props.highlight.libraryItem?.title} + + + {props.highlight.libraryItem?.author} + + {showConfirmDeleteHighlightId && ( + { + ;(async () => { + const highlightId = showConfirmDeleteHighlightId + const success = await deleteHighlightMutation( + props.highlight.libraryItem?.id || '', + showConfirmDeleteHighlightId + ) + props.mutate() + if (success) { + showSuccessToast('Highlight deleted.', { + position: 'bottom-right', + }) + const event = new CustomEvent('deleteHighlightbyId', { + detail: highlightId, + }) + document.dispatchEvent(event) + } else { + showErrorToast('Error deleting highlight', { + position: 'bottom-right', + }) + } + })() + setShowConfirmDeleteHighlightId(undefined) + }} + onOpenChange={() => setShowConfirmDeleteHighlightId(undefined)} + icon={ + + } + /> + )} + {labelsTarget && ( + { + // Don't actually need to do something here + console.log('update highlight: ', highlight) + }} + onOpenChange={() => { + props.mutate() + setLabelsTarget(undefined) + }} + /> + )} + + ) +} diff --git a/packages/web/components/nav-containers/HomeContainer.tsx b/packages/web/components/nav-containers/HomeContainer.tsx new file mode 100644 index 000000000..59ff4e84a --- /dev/null +++ b/packages/web/components/nav-containers/HomeContainer.tsx @@ -0,0 +1,920 @@ +import * as HoverCard from '@radix-ui/react-hover-card' +import { styled } from '@stitches/react' +import { useRouter } from 'next/router' +import { useCallback, useEffect, useMemo, useReducer, useState } from 'react' +import { Button } from '../elements/Button' +import { AddToLibraryActionIcon } from '../elements/icons/home/AddToLibraryActionIcon' +import { ArchiveActionIcon } from '../elements/icons/home/ArchiveActionIcon' +import { CommentActionIcon } from '../elements/icons/home/CommentActionIcon' +import { RemoveActionIcon } from '../elements/icons/home/RemoveActionIcon' +import { ShareActionIcon } from '../elements/icons/home/ShareActionIcon' +import Pagination from '../elements/Pagination' +import { timeAgo } from '../patterns/LibraryCards/LibraryCardStyles' +import { theme } from '../tokens/stitches.config' +import { useApplyLocalTheme } from '../../lib/hooks/useApplyLocalTheme' +import { useGetHiddenHomeSection } from '../../lib/networking/queries/useGetHiddenHomeSection' +import { + HomeItem, + HomeItemSource, + HomeItemSourceType, + HomeSection, + useGetHomeItems, +} from '../../lib/networking/queries/useGetHome' +import { + SubscriptionType, + useGetSubscriptionsQuery, +} from '../../lib/networking/queries/useGetSubscriptionsQuery' +import { Box, HStack, SpanBox, VStack } from '../elements/LayoutPrimitives' +import { Toaster } from 'react-hot-toast' +import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery' +import useLibraryItemActions from '../../lib/hooks/useLibraryItemActions' + +export function HomeContainer(): JSX.Element { + const router = useRouter() + const homeData = useGetHomeItems() + const { viewerData } = useGetViewerQuery() + + useApplyLocalTheme() + + const viewerUsername = useMemo(() => { + return viewerData?.me?.profile.username + }, [viewerData]) + + useEffect(() => { + window.localStorage.setItem('nav-return', router.asPath) + }, [router.asPath]) + + return ( + + + + {homeData.sections?.map((homeSection, idx) => { + if (homeSection.items.length < 1) { + console.log('empty home section: ', homeSection) + return + } + switch (homeSection.layout) { + case 'just_added': + return ( + + ) + case 'top_picks': + return ( + + ) + case 'quick_links': + return ( + + ) + case 'hidden': + return ( + + ) + default: + console.log('unknown home section: ', homeSection) + return + } + })} + + + ) +} + +type HomeSectionProps = { + homeSection: HomeSection + viewerUsername: string | undefined +} + +const JustAddedHomeSection = (props: HomeSectionProps): JSX.Element => { + const router = useRouter() + return ( + + + + {props.homeSection.title} + + + + + + + {props.homeSection.items.map((homeItem) => { + return + })} + + + ) +} + +const TopPicksHomeSection = (props: HomeSectionProps): JSX.Element => { + const listReducer = ( + state: HomeItem[], + action: { + type: string + itemId?: string + items?: HomeItem[] + } + ) => { + console.log('handling action: ', action) + switch (action.type) { + case 'RESET': + return action.items ?? [] + case 'REMOVE_ITEM': + return state.filter((item) => item.id !== action.itemId) + default: + throw new Error() + } + } + + const [items, dispatchList] = useReducer(listReducer, []) + + function handleDelete(item: HomeItem) { + dispatchList({ + type: 'REMOVE_ITEM', + itemId: item.id, + }) + } + + useEffect(() => { + dispatchList({ + type: 'RESET', + items: props.homeSection.items, + }) + }, [props]) + + return ( + + {items.length > 0 && ( + + {props.homeSection.title} + + )} + + ( + + )} + /> + + ) +} + +const QuickLinksHomeSection = (props: HomeSectionProps): JSX.Element => { + return ( + + + {props.homeSection.title} + + + ( + + )} + /> + + ) +} + +const HiddenHomeSection = (props: HomeSectionProps): JSX.Element => { + const [isHidden, setIsHidden] = useState(true) + return ( + + setIsHidden(!isHidden)} + > + + {props.homeSection.title} + + + {isHidden ? 'Show' : 'Hide'} + + + + {isHidden ? <> : } + + ) +} + +const HiddenHomeSectionView = (): JSX.Element => { + const hiddenSectionData = useGetHiddenHomeSection() + + if (hiddenSectionData.error) { + return Error loading hidden section + } + + if (hiddenSectionData.isValidating) { + return Loading... + } + + if (!hiddenSectionData.section) { + return No hidden section data + } + + return ( + + {hiddenSectionData.section.items.map((homeItem) => { + return + })} + + ) +} + +const CoverImage = styled('img', { + objectFit: 'cover', +}) + +type HomeItemViewProps = { + homeItem: HomeItem + viewerUsername?: string | undefined +} + +const TimeAgo = (props: HomeItemViewProps): JSX.Element => { + return ( + + {timeAgo(props.homeItem.date)} + + ) +} + +const Title = (props: HomeItemViewProps): JSX.Element => { + return ( + + {props.homeItem.title} + + ) +} + +const TitleSmall = (props: HomeItemViewProps): JSX.Element => { + return ( + + {props.homeItem.title} + + ) +} + +type PreviewContentProps = { + previewContent?: string + maxLines?: string +} + +const PreviewContent = (props: PreviewContentProps): JSX.Element => { + return ( + + {props.previewContent ?? ''} + + ) +} + +const JustAddedItemView = (props: HomeItemViewProps): JSX.Element => { + const router = useRouter() + + return ( + { + const path = `/${props.viewerUsername ?? 'me'}/${props.homeItem.slug}` + if (event.metaKey || event.ctrlKey) { + window.open(path, '_blank') + } else { + router.push(path) + } + }} + > + + + + + + + + + + ) +} + +type TopPicksItemViewProps = { + dispatchList: (args: { type: string; itemId?: string }) => void +} + +const TopPicksItemView = ( + props: HomeItemViewProps & TopPicksItemViewProps +): JSX.Element => { + const router = useRouter() + const { archiveItem, deleteItem, moveItem } = useLibraryItemActions() + + return ( + { + const path = `/${props.viewerUsername ?? 'me'}/${props.homeItem.slug}` + if (event.metaKey || event.ctrlKey) { + window.open(path, '_blank') + } else { + router.push(path) + } + }} + alignment="start" + > + + + + + + + + + {props.homeItem.thumbnail && ( + + )} + + <PreviewContent + previewContent={props.homeItem.previewContent} + maxLines="6" + /> + </Box> + <SpanBox css={{ px: '20px' }}></SpanBox> + <HStack css={{ gap: '10px', my: '15px', px: '20px' }}> + {props.homeItem.canSave && ( + <Button + style="homeAction" + onClick={async (event) => { + event.preventDefault() + event.stopPropagation() + + props.dispatchList({ + type: 'REMOVE_ITEM', + itemId: props.homeItem.id, + }) + if (!(await moveItem(props.homeItem.id))) { + props.dispatchList({ + type: 'REPLACE_ITEM', + itemId: props.homeItem.id, + }) + } + }} + > + <AddToLibraryActionIcon + color={theme.colors.homeActionIcons.toString()} + /> + </Button> + )} + {props.homeItem.canArchive && ( + <Button + style="homeAction" + onClick={async (event) => { + event.preventDefault() + event.stopPropagation() + + props.dispatchList({ + type: 'REMOVE_ITEM', + itemId: props.homeItem.id, + }) + if (!(await archiveItem(props.homeItem.id))) { + props.dispatchList({ + type: 'REPLACE_ITEM', + itemId: props.homeItem.id, + }) + } + }} + > + <ArchiveActionIcon + color={theme.colors.homeActionIcons.toString()} + /> + </Button> + )} + {props.homeItem.canDelete && ( + <Button + style="homeAction" + onClick={async (event) => { + event.preventDefault() + event.stopPropagation() + + props.dispatchList({ + type: 'REMOVE_ITEM', + itemId: props.homeItem.id, + }) + const undo = () => { + props.dispatchList({ + type: 'REPLACE_ITEM', + itemId: props.homeItem.id, + }) + } + if (!(await deleteItem(props.homeItem.id, undo))) { + props.dispatchList({ + type: 'REPLACE_ITEM', + itemId: props.homeItem.id, + }) + } + }} + > + <RemoveActionIcon color={theme.colors.homeActionIcons.toString()} /> + </Button> + )} + {props.homeItem.canShare && ( + <Button + style="homeAction" + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + }} + > + <ShareActionIcon color={theme.colors.homeActionIcons.toString()} /> + </Button> + )} + </HStack> + <Box + css={{ mt: '15px', width: '100%', height: '1px', bg: '$homeDivider' }} + /> + </VStack> + ) +} + +const QuickLinkHomeItemView = (props: HomeItemViewProps): JSX.Element => { + const router = useRouter() + + return ( + <VStack + css={{ + mt: '10px', + width: '100%', + px: '10px', + py: '10px', + gap: '5px', + borderRadius: '5px', + '&:hover': { + bg: '#007AFF10', + cursor: 'pointer', + }, + '&:hover .title-text': { + textDecoration: 'underline', + }, + }} + onClick={(event) => { + const path = `/${props.viewerUsername ?? 'me'}/${props.homeItem.slug}` + if (event.metaKey || event.ctrlKey) { + window.open(path, '_blank') + } else { + router.push(path) + } + }} + > + <HStack + distribution="start" + alignment="center" + css={{ width: '100%', gap: '5px', lineHeight: '1' }} + > + <SourceInfo homeItem={props.homeItem} subtle={true} /> + + <SpanBox css={{ ml: 'auto', flexShrink: '0' }}> + <TimeAgo homeItem={props.homeItem} /> + </SpanBox> + </HStack> + <Title homeItem={props.homeItem} /> + <PreviewContent + previewContent={props.homeItem.previewContent} + maxLines="2" + /> + </VStack> + ) +} + +const SiteIconSmall = styled('img', { + width: '16px', + height: '16px', + borderRadius: '100px', +}) + +const SiteIconLarge = styled('img', { + width: '25px', + height: '25px', + borderRadius: '100px', +}) + +type SourceInfoProps = { + subtle?: boolean +} + +const SourceInfo = (props: HomeItemViewProps & SourceInfoProps) => ( + <HoverCard.Root> + <HoverCard.Trigger asChild> + <HStack + distribution="start" + alignment="center" + css={{ + gap: '8px', + height: '16px', + cursor: 'pointer', + flex: '1', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }} + > + {props.homeItem.source.icon && ( + <SiteIconSmall src={props.homeItem.source.icon} /> + )} + <HStack + css={{ + lineHeight: '1', + fontFamily: '$inter', + fontWeight: '500', + fontSize: props.subtle ? '12px' : '13px', + color: props.subtle ? '$homeTextSubtle' : '$homeTextSource', + textDecoration: 'underline', + }} + > + {props.homeItem.source.name} + </HStack> + </HStack> + </HoverCard.Trigger> + <HoverCard.Portal> + <HoverCard.Content sideOffset={5} style={{ zIndex: 5 }}> + <SubscriptionSourceHoverContent source={props.homeItem.source} /> + <HoverCard.Arrow fill={theme.colors.thBackground2.toString()} /> + </HoverCard.Content> + </HoverCard.Portal> + </HoverCard.Root> +) + +type SourceHoverContentProps = { + source: HomeItemSource +} + +const SubscriptionSourceHoverContent = ( + props: SourceHoverContentProps +): JSX.Element => { + const mapSourceType = ( + sourceType: HomeItemSourceType + ): SubscriptionType | undefined => { + switch (sourceType) { + case 'RSS': + case 'NEWSLETTER': + return sourceType as SubscriptionType + default: + return undefined + } + } + const { subscriptions, isValidating } = useGetSubscriptionsQuery( + mapSourceType(props.source.type) + ) + const subscription = useMemo(() => { + if (props.source.id && subscriptions) { + return subscriptions.find((sub) => sub.id == props.source.id) + } + return undefined + }, [subscriptions]) + + return ( + <VStack + alignment="start" + distribution="start" + css={{ + width: '380px', + height: '200px', + bg: '$thBackground2', + borderRadius: '10px', + padding: '15px', + gap: '10px', + boxShadow: theme.shadows.cardBoxShadow.toString(), + }} + > + <HStack + distribution="start" + alignment="center" + css={{ width: '100%', gap: '10px', height: '35px' }} + > + {props.source.icon && <SiteIconLarge src={props.source.icon} />} + <SpanBox + css={{ + fontFamily: '$inter', + fontWeight: '500', + fontSize: '14px', + }} + > + {props.source.name} + </SpanBox> + <SpanBox css={{ ml: 'auto', minWidth: '100px' }}> + {subscription && subscription.status == 'ACTIVE' && ( + <Button style="ctaSubtle" css={{ fontSize: '12px' }}> + Unsubscribe + </Button> + )} + </SpanBox> + </HStack> + <SpanBox + css={{ + fontFamily: '$inter', + fontSize: '13px', + color: '$homeTextBody', + }} + > + {subscription ? <>{subscription.description}</> : <></>} + </SpanBox> + </VStack> + ) +} diff --git a/packages/web/components/tokens/stitches.config.ts b/packages/web/components/tokens/stitches.config.ts index 4351694c9..99dd8d755 100644 --- a/packages/web/components/tokens/stitches.config.ts +++ b/packages/web/components/tokens/stitches.config.ts @@ -416,7 +416,7 @@ const blackThemeSpec = { const apolloThemeSpec = { colors: { - readerBg: '#6A6968', + readerBg: '#474747', readerFont: '#F3F3F3', readerMargin: '#474747', readerFontHighContrast: 'white', diff --git a/packages/web/lib/hooks/useLibraryItemActions.tsx b/packages/web/lib/hooks/useLibraryItemActions.tsx new file mode 100644 index 000000000..d2b4d28ab --- /dev/null +++ b/packages/web/lib/hooks/useLibraryItemActions.tsx @@ -0,0 +1,69 @@ +import { useState, useEffect, 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' + +export default function useLibraryItemActions() { + const archiveItem = useCallback(async (itemId: string) => { + const result = await setLinkArchivedMutation({ + linkId: itemId, + archived: true, + }) + + if (result) { + showSuccessToast('Link archived', { position: 'bottom-right' }) + } else { + showErrorToast('Error archiving link', { position: 'bottom-right' }) + } + + return !!result + }, []) + + const deleteItem = useCallback(async (itemId: string, undo: () => void) => { + const result = await deleteLinkMutation(itemId) + + if (result) { + showSuccessToastWithUndo('Item removed', async () => { + const result = await updatePageMutation({ + pageId: itemId, + state: State.SUCCEEDED, + }) + + undo() + + 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 setLinkArchivedMutation({ + linkId: itemId, + archived: true, + }) + + if (result) { + showSuccessToast('Link archived', { position: 'bottom-right' }) + } else { + showErrorToast('Error archiving link', { position: 'bottom-right' }) + } + + return !!result + }, []) + + return { archiveItem, deleteItem, moveItem } +} diff --git a/packages/web/lib/networking/mutations/moveToLibraryMutation.ts b/packages/web/lib/networking/mutations/moveToLibraryMutation.ts new file mode 100644 index 000000000..205312dd6 --- /dev/null +++ b/packages/web/lib/networking/mutations/moveToLibraryMutation.ts @@ -0,0 +1,41 @@ +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 + } +}