From 80bded24db276a6ec695808c69a1cb49b43d36e4 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 21 Jun 2024 12:36:40 +0800 Subject: [PATCH 1/2] Rename web container files --- .../components/nav-containers/highlights.tsx | 319 ------- .../web/components/nav-containers/home.tsx | 887 ------------------ packages/web/pages/l/[section].tsx | 4 +- 3 files changed, 2 insertions(+), 1208 deletions(-) delete mode 100644 packages/web/components/nav-containers/highlights.tsx delete mode 100644 packages/web/components/nav-containers/home.tsx diff --git a/packages/web/components/nav-containers/highlights.tsx b/packages/web/components/nav-containers/highlights.tsx deleted file mode 100644 index ef1358249..000000000 --- a/packages/web/components/nav-containers/highlights.tsx +++ /dev/null @@ -1,319 +0,0 @@ -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/home.tsx b/packages/web/components/nav-containers/home.tsx deleted file mode 100644 index 8c4a3d6b6..000000000 --- a/packages/web/components/nav-containers/home.tsx +++ /dev/null @@ -1,887 +0,0 @@ -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' - -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() - 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={(event) => { - event.preventDefault() - event.stopPropagation() - - props.dispatchList({ - type: 'REMOVE_ITEM', - itemId: props.homeItem.id, - }) - }} - > - <AddToLibraryActionIcon - color={theme.colors.homeActionIcons.toString()} - /> - </Button> - )} - {props.homeItem.canArchive && ( - <Button - style="homeAction" - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - - props.dispatchList({ - type: 'REMOVE_ITEM', - itemId: props.homeItem.id, - }) - }} - > - <ArchiveActionIcon - color={theme.colors.homeActionIcons.toString()} - /> - </Button> - )} - {props.homeItem.canDelete && ( - <Button - style="homeAction" - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - - props.dispatchList({ - type: 'REMOVE_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/pages/l/[section].tsx b/packages/web/pages/l/[section].tsx index fc943e05b..782e7e14f 100644 --- a/packages/web/pages/l/[section].tsx +++ b/packages/web/pages/l/[section].tsx @@ -4,10 +4,10 @@ import { NavigationLayout, NavigationSection, } from '../../components/templates/NavigationLayout' -import { HomeContainer } from '../../components/nav-containers/home' +import { HomeContainer } from '../../components/nav-containers/HomeContainer' import { LibraryContainer } from '../../components/templates/library/LibraryContainer' import { useMemo } from 'react' -import { HighlightsContainer } from '../../components/nav-containers/highlights' +import { HighlightsContainer } from '../../components/nav-containers/HighlightsContainer' export default function Home(): JSX.Element { const router = useRouter() From 5c85257cdbb8d1eaf869125accf260c463771197 Mon Sep 17 00:00:00 2001 From: Jackson Harper <jacksonh@gmail.com> Date: Fri, 21 Jun 2024 13:20:27 +0800 Subject: [PATCH 2/2] 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 ( + <VStack + css={{ + maxWidth: '70%', + padding: '20px', + margin: '30px 50px 0 0', + }} + > + {highlights.map((highlight) => { + return ( + viewer.viewerData?.me && ( + <HighlightCard + key={highlight.id} + highlight={highlight} + viewer={viewer.viewerData.me} + router={router} + mutate={mutate} + /> + ) + ) + })} + </VStack> + ) +} + +type HighlightCardProps = { + highlight: Highlight + viewer: UserBasicData + router: NextRouter + mutate: () => void +} + +type HighlightAnnotationProps = { + highlight: Highlight +} + +function HighlightAnnotation({ + highlight, +}: HighlightAnnotationProps): JSX.Element { + const [noteMode, setNoteMode] = useState<'edit' | 'preview'>('preview') + const [annotation, setAnnotation] = useState(highlight.annotation) + + return ( + <HighlightViewNote + targetId={highlight.id} + text={annotation} + placeHolder="Add notes to this highlight..." + highlight={highlight} + mode={noteMode} + setEditMode={setNoteMode} + updateHighlight={(highlight) => { + setAnnotation(highlight.annotation) + }} + /> + ) +} + +function HighlightCard(props: HighlightCardProps): JSX.Element { + const [isOpen, setIsOpen] = useState(false) + const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] = + useState<undefined | string>(undefined) + const [labelsTarget, setLabelsTarget] = useState<Highlight | undefined>( + undefined + ) + + const viewInReader = useCallback( + (highlightId: string) => { + const router = props.router + const viewer = props.viewer + const item = props.highlight.libraryItem + + if (!router || !router.isReady || !viewer || !item) { + showErrorToast('Error navigating to highlight') + return + } + + router.push( + { + pathname: '/[username]/[slug]', + query: { + username: viewer.profile.username, + slug: item.slug, + }, + hash: highlightId, + }, + `${viewer.profile.username}/${item.slug}#${highlightId}`, + { + scroll: false, + } + ) + }, + [props.highlight.libraryItem, props.viewer, props.router] + ) + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + middleware: [ + offset({ + mainAxis: -25, + }), + size(), + ], + placement: 'top-end', + whileElementsMounted: autoUpdate, + }) + + const hover = useHover(context) + + const { getReferenceProps, getFloatingProps } = useInteractions([hover]) + + return ( + <VStack + ref={refs.setReference} + {...getReferenceProps()} + css={{ + width: '100%', + fontFamily: '$inter', + padding: '20px', + marginBottom: '20px', + bg: '$thBackground2', + borderRadius: '8px', + cursor: 'pointer', + }} + > + <Box + ref={refs.setFloating} + style={floatingStyles} + {...getFloatingProps()} + > + <HighlightHoverActions + viewer={props.viewer} + highlight={props.highlight} + isHovered={isOpen ?? false} + viewInReader={viewInReader} + setLabelsTarget={setLabelsTarget} + setShowConfirmDeleteHighlightId={setShowConfirmDeleteHighlightId} + /> + </Box> + <Box + css={{ + width: '30px', + height: '5px', + backgroundColor: highlightColor(props.highlight.color), + borderRadius: '2px', + }} + /> + <Box + css={{ + color: '$thText', + fontSize: '11px', + marginTop: '10px', + fontWeight: 300, + }} + > + {timeAgo(props.highlight.updatedAt)} + </Box> + {props.highlight.quote && ( + <ReactMarkdown remarkPlugins={[remarkGfm]}> + {props.highlight.quote} + </ReactMarkdown> + )} + <HighlightAnnotation highlight={props.highlight} /> + {props.highlight.labels && ( + <HStack + css={{ + marginBottom: '10px', + }} + > + {props.highlight.labels.map((label) => { + return ( + <LabelChip key={label.id} color={label.color} text={label.name} /> + ) + })} + </HStack> + )} + <Box + css={{ + color: '$thText', + fontSize: '12px', + lineHeight: '20px', + fontWeight: 300, + marginBottom: '10px', + }} + > + {props.highlight.libraryItem?.title} + </Box> + <Box + css={{ + color: '$grayText', + fontSize: '12px', + lineHeight: '20px', + fontWeight: 300, + }} + > + {props.highlight.libraryItem?.author} + </Box> + {showConfirmDeleteHighlightId && ( + <ConfirmationModal + message={'Are you sure you want to delete this highlight?'} + onAccept={() => { + ;(async () => { + const highlightId = showConfirmDeleteHighlightId + const success = await deleteHighlightMutation( + props.highlight.libraryItem?.id || '', + showConfirmDeleteHighlightId + ) + props.mutate() + if (success) { + showSuccessToast('Highlight deleted.', { + position: 'bottom-right', + }) + const event = new CustomEvent('deleteHighlightbyId', { + detail: highlightId, + }) + document.dispatchEvent(event) + } else { + showErrorToast('Error deleting highlight', { + position: 'bottom-right', + }) + } + })() + setShowConfirmDeleteHighlightId(undefined) + }} + onOpenChange={() => setShowConfirmDeleteHighlightId(undefined)} + icon={ + <TrashIcon + size={40} + color={theme.colors.grayTextContrast.toString()} + /> + } + /> + )} + {labelsTarget && ( + <SetHighlightLabelsModalPresenter + highlight={labelsTarget} + highlightId={labelsTarget.id} + onUpdate={(highlight) => { + // Don't actually need to do something here + console.log('update highlight: ', highlight) + }} + onOpenChange={() => { + props.mutate() + setLabelsTarget(undefined) + }} + /> + )} + </VStack> + ) +} 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 ( + <VStack + distribution="start" + alignment="center" + css={{ + width: '100%', + bg: '$readerBg', + pt: '45px', + minHeight: '100vh', + minWidth: '320px', + '@mdDown': { + pt: '0px', + mt: '80px', + }, + }} + > + <Toaster /> + <VStack + distribution="start" + css={{ + width: '680px', + gap: '50px', + minHeight: '100vh', + '@mdDown': { + gap: '40px', + width: '100%', + }, + }} + > + {homeData.sections?.map((homeSection, idx) => { + if (homeSection.items.length < 1) { + console.log('empty home section: ', homeSection) + return <SpanBox key={`section-${idx}`}></SpanBox> + } + switch (homeSection.layout) { + case 'just_added': + return ( + <JustAddedHomeSection + key={`section-${idx}`} + homeSection={homeSection} + viewerUsername={viewerUsername} + /> + ) + case 'top_picks': + return ( + <TopPicksHomeSection + key={`section-${idx}`} + homeSection={homeSection} + viewerUsername={viewerUsername} + /> + ) + case 'quick_links': + return ( + <QuickLinksHomeSection + key={`section-${idx}`} + homeSection={homeSection} + viewerUsername={viewerUsername} + /> + ) + case 'hidden': + return ( + <HiddenHomeSection + key={`section-${idx}`} + homeSection={homeSection} + viewerUsername={viewerUsername} + /> + ) + default: + console.log('unknown home section: ', homeSection) + return <SpanBox key={`section-${idx}`}></SpanBox> + } + })} + </VStack> + </VStack> + ) +} + +type HomeSectionProps = { + homeSection: HomeSection + viewerUsername: string | undefined +} + +const JustAddedHomeSection = (props: HomeSectionProps): JSX.Element => { + const router = useRouter() + return ( + <VStack + distribution="start" + css={{ + width: '100%', + height: '100%', + gap: '20px', + }} + > + <HStack + css={{ + width: '100%', + lineHeight: '1', + '@mdDown': { + px: '20px', + }, + }} + distribution="start" + alignment="start" + > + <SpanBox + css={{ + fontFamily: '$inter', + fontSize: '16px', + fontWeight: '600', + color: '$homeTextTitle', + overflow: 'hidden', + textOverflow: 'ellipsis', + wordBreak: 'break-word', + display: '-webkit-box', + '-webkit-line-clamp': '2', + '-webkit-box-orient': 'vertical', + }} + > + {props.homeSection.title} + </SpanBox> + <SpanBox + css={{ + ml: 'auto', + fontFamily: '$inter', + fontSize: '13px', + fontWeight: '400', + color: '$homeTextTitle', + }} + > + <Button + style="link" + onClick={(event) => { + router.push('/l/library') + event.preventDefault() + }} + css={{ + '&:hover': { + textDecoration: 'underline', + }, + }} + > + View All + </Button> + </SpanBox> + </HStack> + <HStack + css={{ + width: '100%', + height: '100%', + lineHeight: '1', + overflowX: 'scroll', + gap: '25px', + scrollbarWidth: 'none', + '::-webkit-scrollbar': { + display: 'none', + }, + '@mdDown': { + px: '20px', + }, + }} + distribution="start" + alignment="start" + > + {props.homeSection.items.map((homeItem) => { + return <JustAddedItemView key={homeItem.id} homeItem={homeItem} /> + })} + </HStack> + </VStack> + ) +} + +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 ( + <VStack + distribution="start" + css={{ + width: '100%', + gap: '20px', + '@mdDown': { + gap: '10px', + }, + }} + > + {items.length > 0 && ( + <SpanBox + css={{ + fontFamily: '$inter', + fontSize: '16px', + fontWeight: '600', + color: '$homeTextTitle', + '@mdDown': { + px: '20px', + }, + }} + > + {props.homeSection.title} + </SpanBox> + )} + + <Pagination + items={items} + itemsPerPage={10} + loadMoreButtonText="Load more Top Picks" + render={(homeItem) => ( + <TopPicksItemView + key={homeItem.id} + homeItem={homeItem} + dispatchList={dispatchList} + /> + )} + /> + </VStack> + ) +} + +const QuickLinksHomeSection = (props: HomeSectionProps): JSX.Element => { + return ( + <VStack + distribution="start" + css={{ + width: '100%', + gap: '10px', + bg: '$homeCardHover', + py: '20px', + px: '20px', + borderRadius: '5px', + }} + > + <SpanBox + css={{ + fontFamily: '$inter', + fontSize: '12px', + fontWeight: '500', + textTransform: 'uppercase', + color: '$ctaBlue', + bg: '#007AFF20', + px: '10px', + py: '5px', + borderRadius: '5px', + }} + > + {props.homeSection.title} + </SpanBox> + + <Pagination + items={props.homeSection.items} + itemsPerPage={8} + render={(homeItem) => ( + <QuickLinkHomeItemView key={homeItem.id} homeItem={homeItem} /> + )} + /> + </VStack> + ) +} + +const HiddenHomeSection = (props: HomeSectionProps): JSX.Element => { + const [isHidden, setIsHidden] = useState(true) + return ( + <VStack + distribution="start" + css={{ + width: '100%', + gap: '20px', + marginBottom: '40px', + }} + > + <HStack + distribution="start" + alignment="center" + css={{ + gap: '10px', + cursor: 'pointer', + }} + onClick={() => setIsHidden(!isHidden)} + > + <SpanBox + css={{ + fontFamily: '$inter', + fontSize: '16px', + fontWeight: '600', + color: '$homeTextTitle', + }} + > + {props.homeSection.title} + </SpanBox> + <SpanBox + css={{ + fontFamily: '$inter', + fontSize: '13px', + color: '$readerFont', + }} + > + {isHidden ? 'Show' : 'Hide'} + </SpanBox> + </HStack> + + {isHidden ? <></> : <HiddenHomeSectionView />} + </VStack> + ) +} + +const HiddenHomeSectionView = (): JSX.Element => { + const hiddenSectionData = useGetHiddenHomeSection() + + if (hiddenSectionData.error) { + return <SpanBox>Error loading hidden section</SpanBox> + } + + if (hiddenSectionData.isValidating) { + return <SpanBox>Loading...</SpanBox> + } + + if (!hiddenSectionData.section) { + return <SpanBox>No hidden section data</SpanBox> + } + + return ( + <VStack + distribution="start" + css={{ + width: '100%', + }} + > + {hiddenSectionData.section.items.map((homeItem) => { + return <QuickLinkHomeItemView key={homeItem.id} homeItem={homeItem} /> + })} + </VStack> + ) +} + +const CoverImage = styled('img', { + objectFit: 'cover', +}) + +type HomeItemViewProps = { + homeItem: HomeItem + viewerUsername?: string | undefined +} + +const TimeAgo = (props: HomeItemViewProps): JSX.Element => { + return ( + <HStack + distribution="start" + alignment="center" + css={{ + fontSize: '12px', + fontWeight: 'medium', + fontFamily: '$inter', + color: '$homeTextSubtle', + flexShrink: '0', + }} + > + {timeAgo(props.homeItem.date)} + </HStack> + ) +} + +const Title = (props: HomeItemViewProps): JSX.Element => { + return ( + <HStack + className="title-text" + distribution="start" + alignment="center" + css={{ + mb: '6px', + fontSize: '18px', + lineHeight: '24px', + fontWeight: '600', + fontFamily: '$inter', + color: '$homeTextTitle', + overflow: 'hidden', + textOverflow: 'ellipsis', + wordBreak: 'break-word', + display: '-webkit-box', + '-webkit-line-clamp': '3', + '-webkit-box-orient': 'vertical', + '&:title-text': { + transition: 'text-decoration 0.3s ease', + }, + '@mdDown': { + fontSize: '16px', + lineHeight: '20px', + }, + }} + > + {props.homeItem.title} + </HStack> + ) +} + +const TitleSmall = (props: HomeItemViewProps): JSX.Element => { + return ( + <HStack + className="title-text" + distribution="start" + alignment="center" + css={{ + fontSize: '14px', + lineHeight: '21px', + minHeight: '42px', // always have two lines of space + fontWeight: '500', + fontFamily: '$inter', + color: '$homeTextTitle', + overflow: 'hidden', + textOverflow: 'ellipsis', + wordBreak: 'break-word', + display: '-webkit-box', + '-webkit-line-clamp': '3', + '-webkit-box-orient': 'vertical', + }} + > + {props.homeItem.title} + </HStack> + ) +} + +type PreviewContentProps = { + previewContent?: string + maxLines?: string +} + +const PreviewContent = (props: PreviewContentProps): JSX.Element => { + return ( + <SpanBox + css={{ + fontFamily: '$inter', + fontSize: '14px', + lineHeight: '21px', + overflow: 'hidden', + textOverflow: 'ellipsis', + wordBreak: 'break-word', + display: '-webkit-box', + '-webkit-line-clamp': props.maxLines ?? '3', + '-webkit-box-orient': 'vertical', + '@mdDown': { + '-webkit-line-clamp': '3', + }, + }} + > + {props.previewContent ?? ''} + </SpanBox> + ) +} + +const JustAddedItemView = (props: HomeItemViewProps): JSX.Element => { + const router = useRouter() + + return ( + <VStack + css={{ + minWidth: '377px', + gap: '5px', + padding: '12px', + cursor: 'pointer', + bg: '$homeCardHover', + borderRadius: '5px', + '&:hover': { + bg: '$homeCardHover', + }, + '&:hover .title-text': { + textDecoration: 'underline', + }, + '@mdDown': { + minWidth: '282px', + }, + }} + 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> + + <TitleSmall homeItem={props.homeItem} /> + </VStack> + ) +} + +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 ( + <VStack + css={{ + width: '100%', + pt: '15px', + cursor: 'pointer', + borderRadius: '5px', + '@mdDown': { + borderRadius: '0px', + }, + '&:hover': { + bg: '$homeCardHover', + }, + '&: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) + } + }} + alignment="start" + > + <Box css={{ width: '100%', gap: '10px', px: '20px' }}> + <HStack + distribution="start" + alignment="center" + css={{ gap: '5px', lineHeight: '1', mb: '10px' }} + > + <SourceInfo homeItem={props.homeItem} /> + <SpanBox css={{ '@mdDown': { ml: 'auto' } }}> + <TimeAgo homeItem={props.homeItem} /> + </SpanBox> + </HStack> + + {props.homeItem.thumbnail && ( + <CoverImage + css={{ + width: '120px', + height: '70px', + borderRadius: '4px', + marginLeft: '10px', + float: 'right', + }} + src={props.homeItem.thumbnail} + ></CoverImage> + )} + <Title homeItem={props.homeItem} /> + <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 + } +}