Merge pull request #4012 from omnivore-app/feature/just-added-section
add just added section to home
This commit is contained in:
@ -1324,7 +1324,7 @@ export type HomeItemSource = {
|
||||
__typename?: 'HomeItemSource';
|
||||
icon?: Maybe<Scalars['String']>;
|
||||
id?: Maybe<Scalars['ID']>;
|
||||
name: Scalars['String'];
|
||||
name?: Maybe<Scalars['String']>;
|
||||
type: HomeItemSourceType;
|
||||
url?: Maybe<Scalars['String']>;
|
||||
};
|
||||
@ -6117,7 +6117,7 @@ export type HomeItemResolvers<ContextType = ResolverContext, ParentType extends
|
||||
export type HomeItemSourceResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HomeItemSource'] = ResolversParentTypes['HomeItemSource']> = {
|
||||
icon?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
name?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
type?: Resolver<ResolversTypes['HomeItemSourceType'], ParentType, ContextType>;
|
||||
url?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
|
||||
@ -1192,7 +1192,7 @@ type HomeItem {
|
||||
type HomeItemSource {
|
||||
icon: String
|
||||
id: ID
|
||||
name: String!
|
||||
name: String
|
||||
type: HomeItemSourceType!
|
||||
url: String
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
updateLibraryItem,
|
||||
} from '../services/library_item'
|
||||
import { Feature, getScores } from '../services/score'
|
||||
import { enqueueUpdateHomeJob } from '../utils/createTask'
|
||||
import { lanaugeToCode } from '../utils/helpers'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
@ -81,4 +82,12 @@ export const scoreLibraryItem = async (
|
||||
true
|
||||
)
|
||||
logger.info('Library item scored', data)
|
||||
|
||||
try {
|
||||
await enqueueUpdateHomeJob({
|
||||
userId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to enqueue update home job', error)
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,19 +104,57 @@ const publicItemToCandidate = (item: PublicItem): Candidate => ({
|
||||
score: 0,
|
||||
})
|
||||
|
||||
const selectCandidates = async (user: User): Promise<Array<Candidate>> => {
|
||||
const userId = user.id
|
||||
// get last 100 library items saved and not seen by user
|
||||
const getJustAddedCandidates = async (
|
||||
userId: string,
|
||||
limit = 5 // limit to 5 just added candidates
|
||||
): Promise<Array<Candidate>> => {
|
||||
const libraryItems = await searchLibraryItems(
|
||||
{
|
||||
size: 100,
|
||||
size: limit,
|
||||
includeContent: false,
|
||||
query: `-is:seen wordsCount:>0`,
|
||||
useFolders: true, // only show items in inbox folder
|
||||
query: `in:inbox saved:"this week"`,
|
||||
},
|
||||
userId
|
||||
)
|
||||
|
||||
logger.info(`Found ${libraryItems.length} library items`)
|
||||
logger.info(`Found ${libraryItems.length} just added library items`)
|
||||
|
||||
// get subscriptions for the library items
|
||||
const subscriptionNames = libraryItems
|
||||
.filter((item) => !!item.subscription)
|
||||
.map((item) => item.subscription as string)
|
||||
|
||||
const subscriptions = await findSubscriptionsByNames(
|
||||
userId,
|
||||
subscriptionNames
|
||||
)
|
||||
|
||||
// map library items to candidates
|
||||
const justAddedCandidates: Array<Candidate> = libraryItems.map((item) =>
|
||||
libraryItemToCandidate(item, subscriptions)
|
||||
)
|
||||
|
||||
return justAddedCandidates
|
||||
}
|
||||
|
||||
const selectCandidates = async (
|
||||
user: User,
|
||||
excludes: Array<string> = [],
|
||||
limit = 100
|
||||
): Promise<Array<Candidate>> => {
|
||||
const userId = user.id
|
||||
// get last 100 library items saved and not seen by user
|
||||
const libraryItems = await searchLibraryItems(
|
||||
{
|
||||
size: limit,
|
||||
includeContent: false,
|
||||
query: `in:inbox -is:seen -includes:${excludes.join(',')}`,
|
||||
},
|
||||
userId
|
||||
)
|
||||
|
||||
logger.info(`Found ${libraryItems.length} not just added library items`)
|
||||
|
||||
// get subscriptions for the library items
|
||||
const subscriptionNames = libraryItems
|
||||
@ -202,7 +240,6 @@ const rankCandidates = async (
|
||||
}
|
||||
|
||||
const redisKey = (userId: string) => `home:${userId}`
|
||||
const MAX_FEED_ITEMS = 500
|
||||
|
||||
export const getHomeSections = async (
|
||||
userId: string,
|
||||
@ -283,34 +320,17 @@ const appendSectionsToHome = async (
|
||||
const ttl = 86_400_000
|
||||
pipeline.zremrangebyscore(key, '-inf', Date.now() - ttl)
|
||||
|
||||
// keep only the top MAX_FEED_ITEMS items
|
||||
pipeline.zremrangebyrank(key, 0, -(MAX_FEED_ITEMS + 1))
|
||||
// keep only the new sections and remove the oldest ones
|
||||
pipeline.zremrangebyrank(key, 0, -(sections.length + 1))
|
||||
|
||||
logger.info('Adding home sections to redis')
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
const mixHomeItems = (rankedHomeItems: Array<Candidate>): Array<Section> => {
|
||||
// find the median word count
|
||||
const wordCounts = rankedHomeItems.map((item) => item.wordCount)
|
||||
wordCounts.sort((a, b) => a - b)
|
||||
const medianWordCount = wordCounts[Math.floor(wordCounts.length / 2)]
|
||||
// separate items into two groups based on word count
|
||||
const shortItems: Array<Candidate> = []
|
||||
const longItems: Array<Candidate> = []
|
||||
for (const item of rankedHomeItems) {
|
||||
if (item.wordCount < medianWordCount) {
|
||||
shortItems.push(item)
|
||||
} else {
|
||||
longItems.push(item)
|
||||
}
|
||||
}
|
||||
// initialize empty batches
|
||||
const batches: Array<Array<Candidate>> = Array.from(
|
||||
{ length: Math.floor(rankedHomeItems.length / 10) },
|
||||
() => []
|
||||
)
|
||||
|
||||
const mixHomeItems = (
|
||||
justAddedCandidates: Array<Candidate>,
|
||||
rankedHomeItems: Array<Candidate>
|
||||
): Array<Section> => {
|
||||
const checkConstraints = (batch: Array<Candidate>, item: Candidate) => {
|
||||
const titleCount = batch.filter((i) => i.title === item.title).length
|
||||
const authorCount = batch.filter((i) => i.author === item.author).length
|
||||
@ -328,14 +348,22 @@ const mixHomeItems = (rankedHomeItems: Array<Candidate>): Array<Section> => {
|
||||
)
|
||||
}
|
||||
|
||||
const candidateToItem = (candidate: Candidate): Item => ({
|
||||
id: candidate.id,
|
||||
type: candidate.type,
|
||||
score: candidate.score,
|
||||
})
|
||||
|
||||
const distributeItems = (
|
||||
items: Array<Candidate>,
|
||||
batches: Array<Array<Candidate>>
|
||||
) => {
|
||||
const batchSize = Math.ceil(items.length / batches.length)
|
||||
|
||||
for (const item of items) {
|
||||
let added = false
|
||||
for (const batch of batches) {
|
||||
if (batch.length < 5 && checkConstraints(batch, item)) {
|
||||
if (batch.length < batchSize && checkConstraints(batch, item)) {
|
||||
batch.push(item)
|
||||
added = true
|
||||
break
|
||||
@ -344,7 +372,7 @@ const mixHomeItems = (rankedHomeItems: Array<Candidate>): Array<Section> => {
|
||||
|
||||
if (!added) {
|
||||
for (const batch of batches) {
|
||||
if (batch.length < 10) {
|
||||
if (batch.length < batchSize) {
|
||||
batch.push(item)
|
||||
break
|
||||
}
|
||||
@ -353,31 +381,51 @@ const mixHomeItems = (rankedHomeItems: Array<Candidate>): Array<Section> => {
|
||||
}
|
||||
}
|
||||
|
||||
// distribute quick link items first
|
||||
distributeItems(shortItems, batches)
|
||||
distributeItems(longItems, batches)
|
||||
// find the median word count
|
||||
const wordCounts = rankedHomeItems.map((item) => item.wordCount)
|
||||
wordCounts.sort((a, b) => a - b)
|
||||
const medianWordCount = wordCounts[Math.floor(wordCounts.length / 2)]
|
||||
// separate items into two groups based on word count
|
||||
const shortItems: Array<Candidate> = []
|
||||
const longItems: Array<Candidate> = []
|
||||
for (const item of rankedHomeItems) {
|
||||
if (item.wordCount < medianWordCount) {
|
||||
shortItems.push(item)
|
||||
} else {
|
||||
longItems.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
// initialize empty batches
|
||||
const numOfBatches = 10
|
||||
const batches = {
|
||||
short: Array.from({ length: numOfBatches }, () => []) as Array<
|
||||
Array<Candidate>
|
||||
>,
|
||||
long: Array.from({ length: numOfBatches }, () => []) as Array<
|
||||
Array<Candidate>
|
||||
>,
|
||||
}
|
||||
|
||||
distributeItems(shortItems, batches.short)
|
||||
distributeItems(longItems, batches.long)
|
||||
|
||||
// convert batches to sections
|
||||
const sections = []
|
||||
for (const batch of batches) {
|
||||
// create a section for all quick links
|
||||
sections.push({
|
||||
items: batch.slice(0, 5).map((item) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
score: item.score,
|
||||
})),
|
||||
layout: 'quick links',
|
||||
})
|
||||
sections.push({
|
||||
items: batches.short.flat().map(candidateToItem),
|
||||
layout: 'quick_links',
|
||||
})
|
||||
|
||||
// create a section for each long item
|
||||
sections.push(
|
||||
...batch.slice(5).map((item) => ({
|
||||
items: [{ id: item.id, type: item.type, score: item.score }],
|
||||
layout: 'long',
|
||||
}))
|
||||
)
|
||||
}
|
||||
sections.push({
|
||||
items: batches.long.flat().map(candidateToItem),
|
||||
layout: 'top_picks',
|
||||
})
|
||||
|
||||
sections.push({
|
||||
items: justAddedCandidates.map(candidateToItem),
|
||||
layout: 'just_added',
|
||||
})
|
||||
|
||||
return sections
|
||||
}
|
||||
@ -395,14 +443,24 @@ export const updateHome = async (data: UpdateHomeJobData) => {
|
||||
|
||||
logger.info(`Updating home for user ${userId}`)
|
||||
|
||||
logger.profile('justAdded')
|
||||
const justAddedCandidates = await getJustAddedCandidates(userId)
|
||||
logger.profile('justAdded', {
|
||||
level: 'info',
|
||||
message: `Found ${justAddedCandidates.length} just added candidates`,
|
||||
})
|
||||
|
||||
logger.profile('selecting')
|
||||
const candidates = await selectCandidates(user)
|
||||
const candidates = await selectCandidates(
|
||||
user,
|
||||
justAddedCandidates.map((c) => c.id)
|
||||
)
|
||||
logger.profile('selecting', {
|
||||
level: 'info',
|
||||
message: `Found ${candidates.length} candidates`,
|
||||
})
|
||||
|
||||
if (candidates.length === 0) {
|
||||
if (!justAddedCandidates.length && !candidates.length) {
|
||||
logger.info('No candidates found')
|
||||
return
|
||||
}
|
||||
@ -419,7 +477,7 @@ export const updateHome = async (data: UpdateHomeJobData) => {
|
||||
// TODO: filter candidates
|
||||
|
||||
logger.profile('mixing')
|
||||
const rankedSections = mixHomeItems(rankedCandidates)
|
||||
const rankedSections = mixHomeItems(justAddedCandidates, rankedCandidates)
|
||||
logger.profile('mixing', {
|
||||
level: 'info',
|
||||
message: `Created ${rankedSections.length} sections`,
|
||||
|
||||
@ -635,6 +635,20 @@ export const functionResolvers = {
|
||||
},
|
||||
},
|
||||
HomeSection: {
|
||||
title: (section: { title?: string; layout: string }) => {
|
||||
if (section.title) return section.title
|
||||
|
||||
switch (section.layout) {
|
||||
case 'just_added':
|
||||
return 'Just Added'
|
||||
case 'top_picks':
|
||||
return 'Top Picks'
|
||||
case 'quick_links':
|
||||
return 'Quick Links'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
},
|
||||
async items(
|
||||
section: {
|
||||
items: Array<{
|
||||
|
||||
@ -3121,7 +3121,7 @@ const schema = gql`
|
||||
|
||||
type HomeItemSource {
|
||||
id: ID
|
||||
name: String!
|
||||
name: String
|
||||
url: String
|
||||
icon: String
|
||||
type: HomeItemSourceType!
|
||||
|
||||
48
packages/web/components/elements/Pagination.tsx
Normal file
48
packages/web/components/elements/Pagination.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from './Button'
|
||||
import { HStack, VStack } from './LayoutPrimitives'
|
||||
|
||||
type PaginationProps<T> = {
|
||||
items: T[]
|
||||
itemsPerPage: number
|
||||
render: (item: T) => React.ReactNode
|
||||
}
|
||||
|
||||
const Pagination = <T,>({
|
||||
items,
|
||||
itemsPerPage,
|
||||
render,
|
||||
}: PaginationProps<T>) => {
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const maxPage = Math.ceil(items.length / itemsPerPage)
|
||||
|
||||
function createChangePageHandler(page: number) {
|
||||
return function handlePageChange() {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
const itemsToShow = items.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
)
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
{itemsToShow.map(render)}
|
||||
<HStack>
|
||||
{Array.from({ length: maxPage }, (_, i) => i + 1).map((pageNum) => (
|
||||
<Button
|
||||
key={pageNum}
|
||||
onClick={createChangePageHandler(pageNum)}
|
||||
disabled={pageNum === currentPage}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
export default Pagination
|
||||
@ -1,18 +1,17 @@
|
||||
import * as HoverCard from '@radix-ui/react-hover-card'
|
||||
import { styled } from '@stitches/react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from '../../components/elements/Button'
|
||||
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 Pagination from '../../components/elements/Pagination'
|
||||
import { timeAgo } from '../../components/patterns/LibraryCards/LibraryCardStyles'
|
||||
import { theme } from '../../components/tokens/stitches.config'
|
||||
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,
|
||||
@ -20,14 +19,15 @@ import {
|
||||
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'
|
||||
import {
|
||||
HStack,
|
||||
SpanBox,
|
||||
VStack,
|
||||
} from './../../components/elements/LayoutPrimitives'
|
||||
|
||||
export default function Home(): JSX.Element {
|
||||
const homeData = useGetHomeItems()
|
||||
@ -58,9 +58,23 @@ export default function Home(): JSX.Element {
|
||||
>
|
||||
{homeData.sections?.map((homeSection, idx) => {
|
||||
switch (homeSection.layout) {
|
||||
case 'long':
|
||||
case 'just_added':
|
||||
return (
|
||||
<LongHomeSection
|
||||
<JustReadHomeSection
|
||||
key={`section-${idx}`}
|
||||
homeSection={homeSection}
|
||||
/>
|
||||
)
|
||||
case 'top_picks':
|
||||
return (
|
||||
<TopPicksHomeSection
|
||||
key={`section-${idx}`}
|
||||
homeSection={homeSection}
|
||||
/>
|
||||
)
|
||||
case 'quick_links':
|
||||
return (
|
||||
<QuickLinksHomeSection
|
||||
key={`section-${idx}`}
|
||||
homeSection={homeSection}
|
||||
/>
|
||||
@ -76,13 +90,92 @@ type HomeSectionProps = {
|
||||
homeSection: HomeSection
|
||||
}
|
||||
|
||||
const LongHomeSection = (props: HomeSectionProps): JSX.Element => {
|
||||
const JustReadHomeSection = (props: HomeSectionProps): JSX.Element => {
|
||||
return (
|
||||
<SpanBox css={{ width: '100%' }}>
|
||||
<VStack
|
||||
distribution="start"
|
||||
css={{
|
||||
width: '100%',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
<SpanBox
|
||||
css={{
|
||||
fontFamily: '$inter',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '$readerText',
|
||||
}}
|
||||
>
|
||||
{props.homeSection.title}
|
||||
</SpanBox>
|
||||
|
||||
{props.homeSection.items.map((homeItem) => {
|
||||
return <HomeItemView key={homeItem.id} homeItem={homeItem} />
|
||||
return <JustReadItemView key={homeItem.id} homeItem={homeItem} />
|
||||
})}
|
||||
</SpanBox>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
const TopPicksHomeSection = (props: HomeSectionProps): JSX.Element => {
|
||||
return (
|
||||
<VStack
|
||||
distribution="start"
|
||||
css={{
|
||||
width: '100%',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
<SpanBox
|
||||
css={{
|
||||
fontFamily: '$inter',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '$readerText',
|
||||
}}
|
||||
>
|
||||
{props.homeSection.title}
|
||||
</SpanBox>
|
||||
|
||||
<Pagination
|
||||
items={props.homeSection.items}
|
||||
itemsPerPage={10}
|
||||
render={(homeItem) => (
|
||||
<LongHomeItemView key={homeItem.id} homeItem={homeItem} />
|
||||
)}
|
||||
/>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
const QuickLinksHomeSection = (props: HomeSectionProps): JSX.Element => {
|
||||
return (
|
||||
<VStack
|
||||
distribution="start"
|
||||
css={{
|
||||
width: '100%',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
<SpanBox
|
||||
css={{
|
||||
fontFamily: '$inter',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '$readerText',
|
||||
}}
|
||||
>
|
||||
{props.homeSection.title}
|
||||
</SpanBox>
|
||||
|
||||
<Pagination
|
||||
items={props.homeSection.items}
|
||||
itemsPerPage={15}
|
||||
render={(homeItem) => (
|
||||
<QuickLinkHomeItemView key={homeItem.id} homeItem={homeItem} />
|
||||
)}
|
||||
/>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
@ -129,7 +222,59 @@ const Title = (props: HomeItemViewProps): JSX.Element => {
|
||||
)
|
||||
}
|
||||
|
||||
const HomeItemView = (props: HomeItemViewProps): JSX.Element => {
|
||||
const JustReadItemView = (props: HomeItemViewProps): JSX.Element => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<VStack
|
||||
css={{
|
||||
width: '100%',
|
||||
padding: '20px',
|
||||
borderRadius: '5px',
|
||||
'&:hover': {
|
||||
bg: '$thBackground',
|
||||
borderRadius: '0px',
|
||||
},
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
window.open(props.homeItem.url, '_blank')
|
||||
} else {
|
||||
router.push(props.homeItem.url)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HStack css={{ width: '100%', gap: '5px' }}>
|
||||
<VStack css={{ gap: '15px' }}>
|
||||
<HStack
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
css={{ gap: '5px', lineHeight: '1' }}
|
||||
>
|
||||
<SourceInfo homeItem={props.homeItem} />
|
||||
<TimeAgo homeItem={props.homeItem} />
|
||||
</HStack>
|
||||
<Title homeItem={props.homeItem} />
|
||||
</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>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
const LongHomeItemView = (props: HomeItemViewProps): JSX.Element => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
@ -203,6 +348,39 @@ const HomeItemView = (props: HomeItemViewProps): JSX.Element => {
|
||||
)
|
||||
}
|
||||
|
||||
const QuickLinkHomeItemView = (props: HomeItemViewProps): JSX.Element => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<VStack
|
||||
css={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
borderRadius: '5px',
|
||||
'&:hover': {
|
||||
bg: '$thBackground',
|
||||
borderRadius: '0px',
|
||||
},
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
window.open(props.homeItem.url, '_blank')
|
||||
} else {
|
||||
router.push(props.homeItem.url)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TimeAgo homeItem={props.homeItem} />
|
||||
<Title homeItem={props.homeItem} />
|
||||
<SpanBox
|
||||
css={{ fontFamily: '$inter', fontSize: '13px', lineHeight: '23px' }}
|
||||
>
|
||||
{props.homeItem.previewContent}
|
||||
</SpanBox>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
const SiteIconSmall = styled('img', {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
|
||||
Reference in New Issue
Block a user