Files
omnivore/packages/web/components/nav-containers/HomeContainer.tsx
2024-06-25 19:40:45 +08:00

965 lines
24 KiB
TypeScript

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'
import { SyncLoader } from 'react-spinners'
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])
if (homeData.error && homeData.errorMessage == 'PENDING') {
return (
<VStack
distribution="center"
alignment="center"
css={{
width: '100%',
bg: '$readerBg',
minHeight: '100vh',
minWidth: '320px',
}}
>
<SyncLoader color={theme.colors.omnivoreGray.toString()} size={8} />
</VStack>
)
}
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) => {
switch (homeSection.layout) {
case 'just_added':
if (homeSection.items.length < 1) {
return <SpanBox key={`section-${idx}`}></SpanBox>
}
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':
if (homeSection.items.length < 1) {
return <SpanBox key={`section-${idx}`}></SpanBox>
}
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[]
}
) => {
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, [])
useEffect(() => {
dispatchList({
type: 'RESET',
items: props.homeSection.items,
})
}, [props])
console.log(
'props.homeSection.items.length: ',
props.homeSection.items.length
)
if (props.homeSection.items.length < 1) {
return (
<VStack
distribution="start"
css={{
height: '540px',
width: '100%',
gap: '20px',
'@mdDown': {
gap: '10px',
},
bg: '$homeCardHover',
}}
>
Your top picks are empty.
</VStack>
)
}
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>
)
}
type TitleSmallProps = {
maxLines?: string
}
const TitleSmall = (
props: HomeItemViewProps & TitleSmallProps
): 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': props.maxLines ?? '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: '#007AFF10',
},
'&: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} maxLines="2" />
</VStack>
)
}
type TopPicksItemViewProps = {
dispatchList: (args: { type: string; itemId?: string }) => void
}
const TopPicksItemView = (
props: HomeItemViewProps & TopPicksItemViewProps
): JSX.Element => {
const router = useRouter()
const { archiveItem, deleteItem, moveItem, shareItem } =
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.canMove && (
<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={async (event) => {
event.preventDefault()
event.stopPropagation()
await shareItem(props.homeItem.title, props.homeItem.url)
}}
>
<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>
)
}