diff --git a/packages/api/src/jobs/score_library_item.ts b/packages/api/src/jobs/score_library_item.ts index 47279ce50..7080d0ea0 100644 --- a/packages/api/src/jobs/score_library_item.ts +++ b/packages/api/src/jobs/score_library_item.ts @@ -65,7 +65,7 @@ export const scoreLibraryItem = async ( }) logger.info('Scores', scores) - const score = scores[libraryItem.id] + const score = scores[libraryItem.id]['score'] if (!score) { logger.error('Failed to score library item', data) throw new Error('Failed to score library item') diff --git a/packages/api/src/jobs/update_home.ts b/packages/api/src/jobs/update_home.ts index cd3afdb40..56a6879fa 100644 --- a/packages/api/src/jobs/update_home.ts +++ b/packages/api/src/jobs/update_home.ts @@ -187,7 +187,7 @@ const rankCandidates = async ( const newScores = await getScores(data) // update scores for candidates candidates.forEach((item) => { - item.score = newScores[item.id] || 0 + item.score = newScores[item.id]['score'] || 0 }) // rank candidates by score in ascending order diff --git a/packages/api/src/services/score.ts b/packages/api/src/services/score.ts index 2aaf1cf9b..981eb6f08 100644 --- a/packages/api/src/services/score.ts +++ b/packages/api/src/services/score.ts @@ -20,7 +20,11 @@ export interface ScoreApiRequestBody { items: Record // item_id -> feature } -export type ScoreApiResponse = Record // item_id -> score +export type ScoreBody = { + score: number +} + +export type ScoreApiResponse = Record // item_id -> score export const getScores = async ( data: ScoreApiRequestBody diff --git a/packages/web/components/elements/Button.tsx b/packages/web/components/elements/Button.tsx index d682ea3fd..69eb8ac72 100644 --- a/packages/web/components/elements/Button.tsx +++ b/packages/web/components/elements/Button.tsx @@ -49,6 +49,21 @@ export const Button = styled('button', { background: `#0056D2`, }, }, + ctaSubtle: { + borderRadius: '5px', + px: '10px', + py: '8px', + fontSize: '14px', + fontWeight: '500', + cursor: 'pointer', + border: '0px solid $yellow3', + bg: 'gray', + color: 'white', + '&:hover': { + border: '0px solid #0056D2', + background: `#0056D2`, + }, + }, tldr: { gap: '10px', display: 'flex', diff --git a/packages/web/components/elements/icons/home/AddToLibraryActionIcon.tsx b/packages/web/components/elements/icons/home/AddToLibraryActionIcon.tsx new file mode 100644 index 000000000..e42a1bb8a --- /dev/null +++ b/packages/web/components/elements/icons/home/AddToLibraryActionIcon.tsx @@ -0,0 +1,33 @@ +/* eslint-disable functional/no-class */ +/* eslint-disable functional/no-this-expression */ +import { IconProps } from '../IconProps' + +import React from 'react' + +export class AddToLibraryActionIcon extends React.Component { + render() { + const strokeColor = (this.props.color || '#D9D9D9').toString() + const backgroundColor = (this.props.color || '#3D3D3D').toString() + + return ( + + + + + + + ) + } +} diff --git a/packages/web/components/elements/icons/home/ArchiveActionIcon.tsx b/packages/web/components/elements/icons/home/ArchiveActionIcon.tsx new file mode 100644 index 000000000..d97f49e5d --- /dev/null +++ b/packages/web/components/elements/icons/home/ArchiveActionIcon.tsx @@ -0,0 +1,47 @@ +/* eslint-disable functional/no-class */ +/* eslint-disable functional/no-this-expression */ +import { IconProps } from '../IconProps' + +import React from 'react' + +export class ArchiveActionIcon extends React.Component { + render() { + const strokeColor = (this.props.color || '#D9D9D9').toString() + const backgroundColor = (this.props.color || '#3D3D3D').toString() + + return ( + + + + + + + + + ) + } +} diff --git a/packages/web/components/elements/icons/home/CommentActionIcon.tsx b/packages/web/components/elements/icons/home/CommentActionIcon.tsx new file mode 100644 index 000000000..d0d704821 --- /dev/null +++ b/packages/web/components/elements/icons/home/CommentActionIcon.tsx @@ -0,0 +1,33 @@ +/* eslint-disable functional/no-class */ +/* eslint-disable functional/no-this-expression */ +import { IconProps } from '../IconProps' + +import React from 'react' + +export class CommentActionIcon extends React.Component { + render() { + const strokeColor = (this.props.color || '#D9D9D9').toString() + const backgroundColor = (this.props.color || '#3D3D3D').toString() + + return ( + + + + + + + ) + } +} diff --git a/packages/web/components/elements/icons/home/RemoveActionIcon.tsx b/packages/web/components/elements/icons/home/RemoveActionIcon.tsx new file mode 100644 index 000000000..ab55125b1 --- /dev/null +++ b/packages/web/components/elements/icons/home/RemoveActionIcon.tsx @@ -0,0 +1,61 @@ +/* eslint-disable functional/no-class */ +/* eslint-disable functional/no-this-expression */ +import { IconProps } from '../IconProps' + +import React from 'react' + +export class RemoveActionIcon extends React.Component { + render() { + const strokeColor = (this.props.color || '#D9D9D9').toString() + const backgroundColor = (this.props.color || '#3D3D3D').toString() + + return ( + + + + + + + + + + + ) + } +} diff --git a/packages/web/components/elements/icons/home/ShareActionIcon.tsx b/packages/web/components/elements/icons/home/ShareActionIcon.tsx new file mode 100644 index 000000000..75648581d --- /dev/null +++ b/packages/web/components/elements/icons/home/ShareActionIcon.tsx @@ -0,0 +1,33 @@ +/* eslint-disable functional/no-class */ +/* eslint-disable functional/no-this-expression */ +import { IconProps } from '../IconProps' + +import React from 'react' + +export class ShareActionIcon extends React.Component { + render() { + const strokeColor = (this.props.color || '#D9D9D9').toString() + const backgroundColor = (this.props.color || '#3D3D3D').toString() + + return ( + + + + + + + ) + } +} diff --git a/packages/web/components/tokens/stitches.config.ts b/packages/web/components/tokens/stitches.config.ts index db11183dc..5a1908fbf 100644 --- a/packages/web/components/tokens/stitches.config.ts +++ b/packages/web/components/tokens/stitches.config.ts @@ -156,6 +156,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } = readerFontHighContrast: 'black', readerTableHeader: '#FFFFFF', readerMargin: 'white', + readerTextSubtle: '#898989', // Avatar Fallback color avatarBg: '#FFEA9F', @@ -216,6 +217,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } = thTextSubtle: '#1E1E1E', thTextSubtle2: '#6A6968', thTextSubtle3: '#ADADAD', + thTextSubtle4: '#EDEDED', thBorderColor: '#E1E1E1', thBorderSubtle: '#EEEEEE', diff --git a/packages/web/lib/networking/queries/useGetHome.tsx b/packages/web/lib/networking/queries/useGetHome.tsx new file mode 100644 index 000000000..b9e68f820 --- /dev/null +++ b/packages/web/lib/networking/queries/useGetHome.tsx @@ -0,0 +1,172 @@ +import { gql } from 'graphql-request' +import useSWR from 'swr' +import { gqlFetcher, makeGqlFetcher, publicGqlFetcher } from '../networkHelpers' + +type HomeResult = { + home: { + edges?: HomeEdge[] + errorCodes?: string[] + } +} + +export type HomeItemResponse = { + error: boolean + isValidating: boolean + errorMessage?: string + sections?: HomeSection[] +} + +export type HomeItem = { + id: string + date: string + title: string + url: string + score: number + source: HomeItemSource + + previewContent?: string + wordCount?: number + + author?: string + broadcastCount?: number + canArchive?: boolean + canComment?: boolean + canDelete?: boolean + canSave?: boolean + canShare?: boolean + dir?: string + + likeCount?: number + saveCount?: number + seen_at?: string + thumbnail?: string +} + +export type HomeItemSourceType = + | 'LIBRARY' + | 'NEWSLETTER' + | 'RECOMMENDATION' + | 'RSS' + +export type HomeItemSource = { + name: string + type: HomeItemSourceType + id?: string + icon?: string + url?: string +} + +export type HomeSection = { + title: string + layout: string + items: HomeItem[] + thumbnail?: string +} + +type HomeEdge = { + cursor: string + node: HomeSection +} + +export function useGetHomeItems(): HomeItemResponse { + const query = gql` + query GetHomeItems($after: String, $first: Int) { + home(first: $first, after: $after) { + ... on HomeSuccess { + edges { + cursor + node { + title + layout + thumbnail + items { + id + title + url + score + thumbnail + previewContent + saveCount + likeCount + broadcastCount + date + author + dir + seen_at + wordCount + source { + id + name + url + icon + type + } + canSave + canComment + canShare + canArchive + canDelete + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + totalCount + } + } + ... on HomeError { + errorCodes + } + } + } + ` + + const variables = { + first: 100, + after: null, + } + + const { data, error, isValidating } = useSWR( + [query, variables.first, variables.after], + makeGqlFetcher(variables) + ) + + if (error) { + return { + error: true, + isValidating, + errorMessage: error.toString(), + } + } + + const result = data as HomeResult + console.log('result: ', result) + + if (result && result.home.errorCodes) { + const errorCodes = result.home.errorCodes + return { + error: true, + isValidating, + errorMessage: errorCodes.length > 0 ? errorCodes[0] : undefined, + } + } + + if (result && result.home && result.home.edges) { + console.log('data', result.home) + return { + error: false, + isValidating, + sections: result.home.edges.map((edge) => { + return edge.node + }), + } + } + + return { + isValidating, + error: !!error, + } +} diff --git a/packages/web/package.json b/packages/web/package.json index a067e1b51..2afe9b389 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-checkbox": "^0.1.5", "@radix-ui/react-dialog": "1.0.5", "@radix-ui/react-dropdown-menu": "2.0.6", + "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-id": "^0.1.1", "@radix-ui/react-popover": "1.0.7", "@radix-ui/react-progress": "^1.0.1", diff --git a/packages/web/pages/justread/debug.tsx b/packages/web/pages/justread/debug.tsx new file mode 100644 index 000000000..2e31bb29a --- /dev/null +++ b/packages/web/pages/justread/debug.tsx @@ -0,0 +1,88 @@ +import { styled } from '@stitches/react' +import { AddToLibraryActionIcon } from '../../components/elements/icons/home/AddToLibraryActionIcon' +import { ArchiveActionIcon } from '../../components/elements/icons/home/ArchiveActionIcon' +import { CommentActionIcon } from '../../components/elements/icons/home/CommentActionIcon' +import { RemoveActionIcon } from '../../components/elements/icons/home/RemoveActionIcon' +import { ShareActionIcon } from '../../components/elements/icons/home/ShareActionIcon' +import { useApplyLocalTheme } from '../../lib/hooks/useApplyLocalTheme' +import { + HStack, + SpanBox, + VStack, +} from './../../components/elements/LayoutPrimitives' + +import * as HoverCard from '@radix-ui/react-hover-card' +import { Button } from '../../components/elements/Button' +import { + HomeItem, + HomeItemSource, + HomeItemSourceType, + HomeSection, + useGetHomeItems, +} from '../../lib/networking/queries/useGetHome' +import { timeAgo } from '../../components/patterns/LibraryCards/LibraryCardStyles' +import { theme } from '../../components/tokens/stitches.config' +import { useRouter } from 'next/router' +import { + SubscriptionType, + useGetSubscriptionsQuery, +} from '../../lib/networking/queries/useGetSubscriptionsQuery' +import { useMemo } from 'react' + +export default function DebugHome(): JSX.Element { + const homeData = useGetHomeItems() + console.log('home sections: ', homeData.sections) + useApplyLocalTheme() + + return ( + + + {homeData.sections?.map((homeSection, idx) => { + return ( + + Section {idx} + Title: {homeSection.title} + Layout: {homeSection.layout} + Layout: {homeSection.thumbnail} + + {homeSection.items.map((homeItem) => { + return ( + + + {' '} + - Title:{' '} + {homeItem.title} + + - Score: {homeItem.score} + - Word count: {homeItem.wordCount} + - Date: {homeItem.date} + - + + ) + })} + + ) + })} + + + ) +} diff --git a/packages/web/pages/justread/index.tsx b/packages/web/pages/justread/index.tsx new file mode 100644 index 000000000..62355e78c --- /dev/null +++ b/packages/web/pages/justread/index.tsx @@ -0,0 +1,334 @@ +import { styled } from '@stitches/react' +import { AddToLibraryActionIcon } from '../../components/elements/icons/home/AddToLibraryActionIcon' +import { ArchiveActionIcon } from '../../components/elements/icons/home/ArchiveActionIcon' +import { CommentActionIcon } from '../../components/elements/icons/home/CommentActionIcon' +import { RemoveActionIcon } from '../../components/elements/icons/home/RemoveActionIcon' +import { ShareActionIcon } from '../../components/elements/icons/home/ShareActionIcon' +import { useApplyLocalTheme } from '../../lib/hooks/useApplyLocalTheme' +import { + HStack, + SpanBox, + VStack, +} from './../../components/elements/LayoutPrimitives' + +import * as HoverCard from '@radix-ui/react-hover-card' +import { Button } from '../../components/elements/Button' +import { + HomeItem, + HomeItemSource, + HomeItemSourceType, + HomeSection, + useGetHomeItems, +} from '../../lib/networking/queries/useGetHome' +import { timeAgo } from '../../components/patterns/LibraryCards/LibraryCardStyles' +import { theme } from '../../components/tokens/stitches.config' +import { useRouter } from 'next/router' +import { + SubscriptionType, + useGetSubscriptionsQuery, +} from '../../lib/networking/queries/useGetSubscriptionsQuery' +import { useMemo } from 'react' + +export default function Home(): JSX.Element { + const homeData = useGetHomeItems() + console.log('home sections: ', homeData.sections) + useApplyLocalTheme() + + return ( + + + {homeData.sections?.map((homeSection, idx) => { + switch (homeSection.layout) { + case 'long': + return ( + + ) + } + })} + + + ) +} + +type HomeSectionProps = { + homeSection: HomeSection +} + +const LongHomeSection = (props: HomeSectionProps): JSX.Element => { + return ( + + {props.homeSection.items.map((homeItem) => { + return + })} + + ) +} + +const CoverImage = styled('img', { + objectFit: 'cover', +}) + +type HomeItemViewProps = { + homeItem: HomeItem +} + +const TimeAgo = (props: HomeItemViewProps): JSX.Element => { + return ( + + {timeAgo(props.homeItem.date)} + + ) +} + +const Title = (props: HomeItemViewProps): JSX.Element => { + return ( + + {props.homeItem.title} + + ) +} + +const HomeItemView = (props: HomeItemViewProps): JSX.Element => { + const router = useRouter() + + return ( + { + if (event.metaKey || event.ctrlKey) { + window.open(props.homeItem.url, '_blank') + } else { + router.push(props.homeItem.url) + } + }} + > + + + + + + + + </VStack> + <SpanBox css={{ ml: 'auto' }}> + {props.homeItem.thumbnail && ( + <CoverImage + css={{ + mt: '6px', + width: '120px', + height: '70px', + borderRadius: '4px', + }} + src={props.homeItem.thumbnail} + ></CoverImage> + )} + </SpanBox> + </HStack> + <SpanBox + css={{ fontFamily: '$inter', fontSize: '13px', lineHeight: '23px' }} + > + {props.homeItem.previewContent} + </SpanBox> + <HStack css={{ gap: '6px', mt: '20px' }}> + <Button style="ghost"> + <AddToLibraryActionIcon /> + </Button> + <Button style="ghost"> + <CommentActionIcon /> + </Button> + <Button style="ghost"> + <ShareActionIcon /> + </Button> + <Button style="ghost"> + <ArchiveActionIcon /> + </Button> + <Button style="ghost"> + <RemoveActionIcon /> + </Button> + </HStack> + </VStack> + ) +} + +const SiteIconSmall = styled('img', { + width: '16px', + height: '16px', + borderRadius: '100px', +}) + +const SiteIconLarge = styled('img', { + width: '25px', + height: '25px', + borderRadius: '100px', +}) + +const SourceInfo = (props: HomeItemViewProps) => ( + <HoverCard.Root> + <HoverCard.Trigger asChild> + <HStack + distribution="start" + alignment="center" + css={{ gap: '5px', cursor: 'pointer' }} + > + {props.homeItem.source.icon && ( + <SiteIconSmall + src={props.homeItem.source.icon} + alt={props.homeItem.source.name} + /> + )} + <HStack + css={{ + lineHeight: '1', + pb: '3px', + fontFamily: '$inter', + fontWeight: '500', + fontSize: '13px', + color: '$readerFont', + textDecoration: 'underline', + }} + > + {props.homeItem.source.name} + </HStack> + </HStack> + </HoverCard.Trigger> + <HoverCard.Portal> + <HoverCard.Content sideOffset={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' }} + > + {props.source.icon && ( + <SiteIconLarge src={props.source.icon} alt={props.source.name} /> + )} + <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: '$thTextSubtle4', + }} + > + {subscription ? <>{subscription.description}</> : <></>} + </SpanBox> + </VStack> + ) +}