First pass at the just read home web ux

This commit is contained in:
Jackson Harper 2024-05-28 17:24:34 +08:00
parent b4ff550df7
commit 5c4f8fc916
8 changed files with 731 additions and 0 deletions

View file

@ -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<IconProps> {
render() {
const strokeColor = (this.props.color || '#D9D9D9').toString()
const backgroundColor = (this.props.color || '#3D3D3D').toString()
return (
<svg
width="50"
height="32"
viewBox="0 0 50 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="50" height="32" rx="4" fill={backgroundColor} />
<g>
<path
d="M30 11.8333V23.5L25 20.1667L20 23.5V11.8333C20 10.9493 20.3512 10.1014 20.9763 9.47631C21.6014 8.85119 22.4493 8.5 23.3333 8.5H26.6667C27.5507 8.5 28.3986 8.85119 29.0237 9.47631C29.6488 10.1014 30 10.9493 30 11.8333Z"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -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<IconProps> {
render() {
const strokeColor = (this.props.color || '#D9D9D9').toString()
const backgroundColor = (this.props.color || '#3D3D3D').toString()
return (
<svg
width="50"
height="32"
viewBox="0 0 50 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="50" height="32" rx="4" fill={backgroundColor} />
<g>
<path
d="M17.5 11.0002C17.5 10.5581 17.6756 10.1342 17.9882 9.82165C18.3007 9.50909 18.7246 9.3335 19.1667 9.3335H30.8333C31.2754 9.3335 31.6993 9.50909 32.0118 9.82165C32.3244 10.1342 32.5 10.5581 32.5 11.0002C32.5 11.4422 32.3244 11.8661 32.0118 12.1787C31.6993 12.4912 31.2754 12.6668 30.8333 12.6668H19.1667C18.7246 12.6668 18.3007 12.4912 17.9882 12.1787C17.6756 11.8661 17.5 11.4422 17.5 11.0002Z"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19.1666 12.6665V20.9998C19.1666 21.4419 19.3422 21.8658 19.6548 22.1783C19.9673 22.4909 20.3913 22.6665 20.8333 22.6665H29.1666C29.6087 22.6665 30.0326 22.4909 30.3451 22.1783C30.6577 21.8658 30.8333 21.4419 30.8333 20.9998V12.6665"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M23.3334 16H26.6667"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -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<IconProps> {
render() {
const strokeColor = (this.props.color || '#D9D9D9').toString()
const backgroundColor = (this.props.color || '#3D3D3D').toString()
return (
<svg
width="50"
height="32"
viewBox="0 0 50 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="50" height="32" rx="4" fill={backgroundColor} />
<g>
<path
d="M17.5 22.6668L18.5833 19.4168C17.647 18.032 17.3083 16.3922 17.6302 14.8021C17.952 13.212 18.9126 11.7799 20.3331 10.772C21.7537 9.76421 23.5377 9.24923 25.3534 9.32285C27.1691 9.39647 28.8929 10.0537 30.2044 11.1723C31.5159 12.2909 32.3257 13.7948 32.4834 15.4043C32.6411 17.0138 32.1358 18.6195 31.0616 19.9228C29.9873 21.226 28.4172 22.1382 26.6432 22.4896C24.8691 22.8411 23.0118 22.6079 21.4167 21.8335L17.5 22.6668Z"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -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<IconProps> {
render() {
const strokeColor = (this.props.color || '#D9D9D9').toString()
const backgroundColor = (this.props.color || '#3D3D3D').toString()
return (
<svg
width="50"
height="32"
viewBox="0 0 50 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="50" height="32" rx="4" fill={backgroundColor} />
<g>
<path
d="M18.3334 11.8335H31.6667"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M23.3334 15.1665V20.1665"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M26.6666 15.1665V20.1665"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19.1666 11.8335L20 21.8335C20 22.2755 20.1756 22.6994 20.4881 23.012C20.8007 23.3246 21.2246 23.5002 21.6666 23.5002H28.3333C28.7753 23.5002 29.1992 23.3246 29.5118 23.012C29.8244 22.6994 30 22.2755 30 21.8335L30.8333 11.8335"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M22.5 11.8333V9.33333C22.5 9.11232 22.5878 8.90036 22.7441 8.74408C22.9004 8.5878 23.1123 8.5 23.3333 8.5H26.6667C26.8877 8.5 27.0996 8.5878 27.2559 8.74408C27.4122 8.90036 27.5 9.11232 27.5 9.33333V11.8333"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -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<IconProps> {
render() {
const strokeColor = (this.props.color || '#D9D9D9').toString()
const backgroundColor = (this.props.color || '#3D3D3D').toString()
return (
<svg
width="49"
height="32"
viewBox="0 0 49 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect y="0.5" width="49" height="31" rx="4" fill={backgroundColor} />
<g>
<path
d="M25.2917 9.6665V12.8332C20.0865 13.647 18.1509 18.207 17.375 22.3332C17.3457 22.4963 21.6374 17.6133 25.2917 17.5832V20.7498L31.625 15.2082L25.2917 9.6665Z"
stroke={strokeColor}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -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',

View file

@ -0,0 +1,244 @@
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
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
}
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
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: 3,
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) {
return {
error: false,
isValidating,
sections: result.home.edges.map((edge) => {
return edge.node
}),
}
}
return {
isValidating,
error: !!error,
}
}
// Fake HomeSubscription data
const source1: HomeItemSource = {
id: 'sub1',
name: 'Tech News',
type: 'NEWSLETTER',
icon: 'https://example.com/icon1.png',
url: 'https://example.com/tech-news',
}
const source2: HomeItemSource = {
id: 'sub2',
name: 'Daily Updates',
type: 'RSS',
icon: 'https://example.com/icon2.png',
url: 'https://example.com/daily-updates',
}
// Fake HomeItem data
export const stubHomeItems: HomeItem[] = [
{
id: 'item1',
date: '2024-05-01',
title: 'Breaking Tech News',
url: 'https://example.com/article1',
source: source1,
previewContent: 'A brief summary of the tech news...',
wordCount: 500,
author: 'John Doe',
broadcastCount: 10,
canArchive: true,
canComment: true,
canDelete: true,
canSave: true,
canShare: true,
dir: 'ltr',
likeCount: 100,
saveCount: 50,
seen_at: '2024-05-02',
thumbnail: 'https://example.com/thumbnail1.png',
},
{
id: 'item2',
date: '2024-05-03',
title: 'Daily Updates: May 3rd',
url: 'https://example.com/article2',
source: source2,
previewContent: 'A brief summary of the daily updates...',
wordCount: 300,
author: 'Jane Smith',
broadcastCount: 5,
canArchive: false,
canComment: true,
canDelete: false,
canSave: true,
canShare: false,
dir: 'ltr',
likeCount: 75,
saveCount: 30,
seen_at: '2024-05-04',
thumbnail: 'https://example.com/thumbnail2.png',
},
{
id: 'item3',
date: '2024-05-05',
title: 'In-Depth Analysis',
url: 'https://example.com/article3',
source: source1,
wordCount: 1500,
canArchive: true,
canComment: true,
canDelete: true,
canSave: true,
canShare: true,
likeCount: 200,
saveCount: 120,
seen_at: '2024-05-06',
},
]

View file

@ -0,0 +1,278 @@
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,
HomeSection,
stubHomeItems,
useGetHomeItems,
} from '../../lib/networking/queries/useGetHome'
import { timeAgo } from '../../components/patterns/LibraryCards/LibraryCardStyles'
import { theme } from '../../components/tokens/stitches.config'
export default function Home(): JSX.Element {
const homeData = useGetHomeItems()
console.log('home sections: ', homeData.sections)
useApplyLocalTheme()
return (
<VStack
distribution="start"
alignment="center"
css={{
width: '100%',
bg: '$readerBg',
pt: '45px',
minHeight: '100vh',
}}
>
<VStack
distribution="start"
css={{
width: '646px',
gap: '40px',
minHeight: '100vh',
}}
>
{homeData.sections?.map((homeSection, idx) => {
switch (homeSection.layout) {
case 'long':
return (
<LongHomeSection
key={`section-${idx}`}
homeSection={homeSection}
/>
)
}
})}
</VStack>
</VStack>
)
}
type HomeSectionProps = {
homeSection: HomeSection
}
const LongHomeSection = (props: HomeSectionProps): JSX.Element => {
return (
<SpanBox css={{ width: '100%' }}>
{props.homeSection.items.map((homeItem) => {
return <HomeItemView key={homeItem.id} homeItem={homeItem} />
})}
</SpanBox>
)
}
const CoverImage = styled('img', {
objectFit: 'cover',
})
type HomeItemViewProps = {
homeItem: HomeItem
}
const TimeAgo = (props: HomeItemViewProps): JSX.Element => {
return (
<HStack
distribution="start"
alignment="center"
css={{
fontSize: '12px',
fontWeight: 'normal',
fontFamily: '$inter',
color: '$readerContrast',
}}
>
{timeAgo(props.homeItem.date)}
</HStack>
)
}
const Title = (props: HomeItemViewProps): JSX.Element => {
return (
<HStack
distribution="start"
alignment="center"
css={{
fontSize: '16px',
lineHeight: '20px',
fontWeight: '600',
fontFamily: '$inter',
color: '$readerText',
}}
>
{props.homeItem.title}
</HStack>
)
}
const HomeItemView = (props: HomeItemViewProps): JSX.Element => {
return (
<VStack
css={{
width: '100%',
padding: '20px',
borderRadius: '5px',
'&:hover': {
bg: '$thBackground',
},
}}
>
<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>
<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' }}
>
<SiteIconSmall
className="Image normal"
src="https://pbs.twimg.com/profile_images/1337055608613253126/r_eiMp2H_400x400.png"
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}>
<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' }}
>
<SiteIconLarge
className="Image normal"
src="https://pbs.twimg.com/profile_images/1337055608613253126/r_eiMp2H_400x400.png"
alt={props.homeItem.source.name}
/>
<SpanBox
css={{
fontFamily: '$inter',
fontWeight: '500',
fontSize: '14px',
}}
>
{props.homeItem.source.name}
</SpanBox>
<SpanBox css={{ ml: 'auto' }}>
<Button style="ctaBlue">+ Follow</Button>
</SpanBox>
</HStack>
<SpanBox
css={{
fontFamily: '$inter',
fontSize: '13px',
color: '$thTextSubtle4',
}}
>
The description of the newsletter or RSS feed, this would lazy load.
</SpanBox>
</VStack>
<HoverCard.Arrow fill={theme.colors.thBackground2.toString()} />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
)