Merge pull request #1601 from omnivore-app/feat/library-cards

feat/library cards
This commit is contained in:
Jackson Harper 2023-03-09 10:59:49 +08:00 committed by GitHub
commit d3e334d00d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
175 changed files with 6601 additions and 5988 deletions

View file

@ -334,6 +334,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
'includes',
'recommendedBy',
'no',
'mode',
],
tokenize: true,
})
@ -424,6 +425,9 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
noFilter && result.noFilters.push(noFilter)
break
}
case 'mode':
// mode is ignored and used only by the frontend
break
}
}
}

View file

@ -84,7 +84,7 @@ const App = () => {
margin={window.margin}
maxWidthPercentage={window.maxWidthPercentage}
lineHeight={window.lineHeight}
highContrastFont={window.prefersHighContrastFont ?? true}
highContrastText={window.prefersHighContrastFont ?? true}
articleMutations={{
createHighlightMutation: (input) =>
mutation('createHighlight', input),

View file

@ -6,7 +6,6 @@ type AvatarProps = {
height: string
fallbackText: string
tooltip?: string
noFade?: boolean
}
export function Avatar(props: AvatarProps): JSX.Element {
@ -19,10 +18,6 @@ export function Avatar(props: AvatarProps): JSX.Element {
borderRadius: '50%',
}}
>
<StyledImage
src={props.imageURL}
css={{ opacity: props.noFade ? 'unset' : '48%' }}
/>
<StyledFallback>{props.fallbackText}</StyledFallback>
</StyledAvatar>
)
@ -35,17 +30,6 @@ const StyledAvatar = styled(Root, {
verticalAlign: 'middle',
overflow: 'hidden',
userSelect: 'none',
border: '1px solid $grayBorder',
})
const StyledImage = styled(Image, {
width: '100%',
height: '100%',
objectFit: 'cover',
'&:hover': {
opacity: '100%',
},
})
const StyledFallback = styled(Fallback, {
@ -54,8 +38,9 @@ const StyledFallback = styled(Fallback, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '$2',
fontWeight: 700,
backgroundColor: '$avatarBg',
fontSize: '15px',
fontWeight: 600,
fontFamily: 'Inter',
color: '$avatarFont',
backgroundColor: '$avatarBg',
})

View file

@ -1,20 +1,14 @@
import { Avatar } from './../elements/Avatar'
import { AngleDownIcon } from './../tokens/icons/AngleDownIcon'
import { HStack } from '../elements/LayoutPrimitives'
type AvatarDropdownProps = {
profileImageURL?: string
userInitials: string
}
export function AvatarDropdown(props: AvatarDropdownProps): JSX.Element {
return (
<HStack alignment="center" css={{ gap: '6px' }}>
<Avatar
imageURL={props.profileImageURL}
height='32px'
fallbackText={props.userInitials}
/>
<Avatar height="32px" fallbackText={props.userInitials} />
</HStack>
)
}

View file

@ -19,16 +19,40 @@ export const Button = styled('button', {
},
},
ctaDarkYellow: {
border: 0,
fontSize: '14px',
border: '1px solid transparent',
fontSize: '13px',
fontWeight: 500,
fontStyle: 'normal',
fontFamily: 'Inter',
borderRadius: '8px',
borderRadius: '5px',
cursor: 'pointer',
color: '$omnivoreGray',
bg: '$omnivoreCtaYellow',
p: '10px 13px',
color: '#3D3D3D',
bg: '#FFEA9F',
p: '10px 15px',
'&:hover': {
bg: '$omnivoreCtaYellow',
},
'&:focus': {
outline: 'none !important',
border: '1px solid $omnivoreCtaYellow',
},
},
cancelGeneric: {
fontSize: '13px',
fontWeight: 500,
fontFamily: 'Inter',
cursor: 'pointer',
color: '#6A6968',
borderRadius: '5px',
border: '1px solid transparent',
p: '10px 15px',
bg: 'transparent',
'&:hover': {
bg: '#EBEBEB',
},
'&:focus': {
outline: 'none !important',
border: '1px solid $omnivoreCtaYellow',
},
},
ctaOutlineYellow: {
boxSizing: 'border-box',
@ -65,7 +89,7 @@ export const Button = styled('button', {
},
'.ctaButtonIcon': {
visibility: 'hidden',
}
},
},
ctaGray: {
border: 0,
@ -211,7 +235,7 @@ export const Button = styled('button', {
},
'&[data-state="selected"]': {
border: '2px solid #F9D354',
}
},
},
},
},
@ -235,6 +259,21 @@ export const IconButton = styled(Button, {
width: 40,
height: 40,
},
searchButton: {
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: '0px',
mr: '5px',
width: '28px',
height: '28px',
color: '#898989',
border: 'unset',
background: '$thBackground',
boxSizing: 'border-box',
borderRadius: 6,
},
},
},
})
})

View file

@ -0,0 +1,55 @@
import { X } from 'phosphor-react'
import { useState } from 'react'
import { Button } from './Button'
import { Box } from './LayoutPrimitives'
type CloseButtonProps = {
close: () => void
}
export function CloseButton(props: CloseButtonProps): JSX.Element {
const [hover, setHover] = useState(false)
return (
<Box
css={{
display: 'flex',
height: '20px',
width: '20px',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '1000px',
background: '$thBackground2',
'&:hover': {
bg: '#898989',
},
}}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
>
<Button
css={{
cursor: 'pointer',
marginLeft: 'auto',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
style="ghost"
onClick={() => {
props.close()
}}
>
<X
width={10}
height={10}
weight="bold"
color={hover ? '#EBEBEB' : '#898989'}
className="xMark"
/>
</Button>
</Box>
)
}

View file

@ -8,15 +8,15 @@ import {
Arrow,
Label,
} from '@radix-ui/react-dropdown-menu'
import { PopperContentProps } from '@radix-ui/react-popover';
import { CSS } from '@stitches/react';
import { PopperContentProps } from '@radix-ui/react-popover'
import { CSS } from '@stitches/react'
import { styled } from './../tokens/stitches.config'
const itemStyles = {
fontSize: '16px',
fontWeight: '500',
py: '12px',
px: '24px',
const StyledItem = styled(Item, {
fontSize: '14px',
fontWeight: '400',
py: '10px',
px: '15px',
borderRadius: 3,
cursor: 'default',
color: '$utilityTextDefault',
@ -25,9 +25,7 @@ const itemStyles = {
outline: 'none',
backgroundColor: '$grayBgHover',
},
}
const StyledItem = styled(Item, itemStyles)
})
const DropdownTrigger = styled(Trigger, {
fontSize: '100%',
@ -44,7 +42,6 @@ const StyledTriggerItem = styled(TriggerItem, {
outline: 'none',
backgroundColor: '$grayBgHover',
},
...itemStyles,
})
export const DropdownContent = styled(Content, {
@ -123,6 +120,7 @@ type DropdownProps = {
disabled?: boolean
css?: CSS
modal?: boolean
onOpenChange?: (open: boolean) => void
}
export const DropdownSeparator = styled(Separator, {
@ -141,14 +139,21 @@ type DropdownOptionProps = {
export function DropdownOption(props: DropdownOptionProps): JSX.Element {
return (
<>
<StyledItem onSelect={props.onSelect}>
<StyledItem
onSelect={() => {
props.onSelect()
}}
onClick={(event) => event.stopPropagation()}
>
{props.title ?? props.children}
</StyledItem>
</>
)
}
export function Dropdown(props: DropdownProps & PopperContentProps): JSX.Element {
export function Dropdown(
props: DropdownProps & PopperContentProps
): JSX.Element {
const {
children,
align,
@ -159,14 +164,15 @@ export function Dropdown(props: DropdownProps & PopperContentProps): JSX.Element
sideOffset = 0,
alignOffset = 0,
css,
modal
modal,
onOpenChange,
} = props
return (
<Root modal={modal}>
<Root modal={modal} onOpenChange={props.onOpenChange}>
<DropdownTrigger disabled={disabled}>{triggerElement}</DropdownTrigger>
<DropdownContent
css={css}
onInteractOutside={(event) => {
onInteractOutside={() => {
// remove focus from dropdown
;(document.activeElement as HTMLElement).blur()
}}

View file

@ -9,7 +9,6 @@ import { EdgeIcon } from './images/EdgeIcon'
import { FirefoxIcon } from './images/FirefoxIcon'
import { SafariIcon } from './images/SafariIcon'
import Link from 'next/link'
import { SaveArticleIcon } from './images/SaveArticleIcon'
const icons = {
'Google Chrome': <ChromeIcon />,

View file

@ -0,0 +1,91 @@
import { useCallback, useState } from 'react'
import { Highlight } from '../../lib/networking/fragments/highlightFragment'
import { updateHighlightMutation } from '../../lib/networking/mutations/updateHighlightMutation'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
import { Button } from './Button'
import { HStack, VStack } from './LayoutPrimitives'
import { StyledTextArea } from './StyledTextArea'
type HighlightNoteTextEditAreaProps = {
setIsEditing: (editing: boolean) => void
highlight: Highlight
updateHighlight: (highlight: Highlight) => void
}
export const HighlightNoteTextEditArea = (
props: HighlightNoteTextEditAreaProps
): JSX.Element => {
const [noteContent, setNoteContent] = useState(
props.highlight.annotation ?? ''
)
const handleNoteContentChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>): void => {
setNoteContent(event.target.value)
},
[setNoteContent]
)
return (
<VStack css={{ width: '100%' }} key="textEditor">
<StyledTextArea
css={{
my: '10px',
minHeight: '200px',
borderRadius: '5px',
p: '10px',
width: '100%',
marginTop: '16px',
resize: 'vertical',
bg: '#EBEBEB',
color: '#3D3D3D',
}}
autoFocus
maxLength={4000}
value={noteContent}
placeholder={'Add your notes...'}
onChange={handleNoteContentChange}
/>
<HStack alignment="center" distribution="end" css={{ width: '100%' }}>
<Button
style="cancelGeneric"
css={{ mr: '$2' }}
onClick={() => {
props.setIsEditing(false)
setNoteContent(props.highlight.annotation ?? '')
}}
>
Cancel
</Button>
<Button
style="ctaDarkYellow"
onClick={async (e) => {
e.preventDefault()
try {
const result = await updateHighlightMutation({
highlightId: props.highlight.id,
annotation: noteContent,
})
console.log('result: ' + result)
if (!result) {
showErrorToast('There was an error updating your highlight.')
} else {
showSuccessToast('Note saved')
props.highlight.annotation = noteContent
props.updateHighlight(props.highlight)
}
} catch (err) {
console.log('error updating annoation', err)
showErrorToast('There was an error updating your highlight.')
}
props.setIsEditing(false)
}}
>
Save
</Button>
</HStack>
</VStack>
)
}

View file

@ -1,6 +1,5 @@
import Link from 'next/link'
import { Info } from 'phosphor-react'
import { Box, VStack } from '../elements/LayoutPrimitives'
import { VStack } from '../elements/LayoutPrimitives'
import { theme } from '../tokens/stitches.config'
import { TooltipWrapped } from './Tooltip'

View file

@ -1,4 +1,4 @@
import { getLuminance, lighten, toHsla } from 'color2k'
import { getLuminance, lighten } from 'color2k'
import { useRouter } from 'next/router'
import { Button } from './Button'
import { SpanBox } from './LayoutPrimitives'
@ -40,21 +40,16 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
<SpanBox
css={{
display: 'inline-table',
margin: '4px',
margin: '2px',
borderRadius: '4px',
color: isDarkMode ? darkThemeTextColor : lightThemeTextColor,
fontSize: '12px',
fontWeight: 'bold',
padding: '2px 5px 2px 5px',
color: lightThemeTextColor,
fontSize: '13px',
fontWeight: '500',
padding: '3px 6px',
whiteSpace: 'nowrap',
cursor: 'pointer',
backgroundClip: 'padding-box',
border: isDarkMode
? `1px solid ${darkThemeTextColor}`
: `1px solid rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.7)`,
backgroundColor: isDarkMode
? `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.08)`
: props.color,
backgroundColor: props.color,
}}
>
{props.text}

View file

@ -77,8 +77,8 @@ const MainContainer = styled(Box, {
border: '1px solid $grayBorderHover',
},
'@mdDown': {
width: '100%'
}
width: '100%',
},
})
const CustomLabelWrapper = styled(Box, {
@ -106,8 +106,8 @@ export const LabelColorDropdown = (props: LabelColorDropdownProps) => {
} = props
const isDarkMode = isDarkTheme()
const iconColor = isDarkMode ? '#FFFFFF': '#0A0806'
const [open, setOpen] = useState<boolean | undefined>(false);
const iconColor = isDarkMode ? '#FFFFFF' : '#0A0806'
const [open, setOpen] = useState<boolean | undefined>(false)
const handleCustomColorChange = (color: string) => {
setLabelColorHex({
@ -118,7 +118,7 @@ export const LabelColorDropdown = (props: LabelColorDropdownProps) => {
const handleOpen = (open: boolean) => {
if (canEdit && open) setOpen(true)
else if((isCreateMode && !canEdit) && open) setOpen(true)
else if (isCreateMode && !canEdit && open) setOpen(true)
else setOpen(false)
}
@ -130,7 +130,7 @@ export const LabelColorDropdown = (props: LabelColorDropdownProps) => {
width: '100%',
'@md': {
minWidth: '170px',
width: 'auto'
width: 'auto',
},
}}
>

View file

@ -0,0 +1,39 @@
import { LIBRARY_LEFT_MENU_WIDTH } from '../templates/homeFeed/LibraryFilterMenu'
import { theme } from '../tokens/stitches.config'
import { OmnivoreFullLogo } from './images/OmnivoreFullLogo'
import { OmnivoreNameLogo } from './images/OmnivoreNameLogo'
import { SpanBox } from './LayoutPrimitives'
export function LogoBox(): JSX.Element {
return (
<>
<SpanBox
css={{
pl: '25px',
height: '24px',
width: LIBRARY_LEFT_MENU_WIDTH,
minWidth: LIBRARY_LEFT_MENU_WIDTH,
'@mdDown': {
display: 'none',
},
}}
>
<OmnivoreFullLogo
showTitle={true}
color={theme.colors.thHighContrast.toString()}
/>
</SpanBox>
<SpanBox
css={{
ml: '15px',
mr: '15px',
'@md': {
display: 'none',
},
}}
>
<OmnivoreNameLogo />
</SpanBox>
</>
)
}

View file

@ -0,0 +1,57 @@
import { DotsThreeVertical, X } from 'phosphor-react'
import { useState } from 'react'
import { Button } from './Button'
import { Box, SpanBox } from './LayoutPrimitives'
export function MenuTrigger(): JSX.Element {
const [hover, setHover] = useState(false)
return (
<SpanBox
css={{
display: 'flex',
height: '20px',
width: '20px',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '1000px',
background: '$thBackground2',
'&:hover': {
bg: '#898989',
},
}}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
>
<DotsThreeVertical
size={25}
weight="bold"
color={hover ? '#EBEBEB' : '#898989'}
/>
{/* color="#ADADAD" />
<Button
css={{
cursor: 'pointer',
marginLeft: 'auto',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
style="ghost"
onClick={() => {
props.close()
}}
>
<X
width={10}
height={10}
weight="bold"
color={hover ? '#EBEBEB' : '#898989'}
className="xMark"
/>
</Button> */}
</SpanBox>
)
}

View file

@ -5,10 +5,9 @@ import {
DeviceMobileCamera,
} from 'phosphor-react'
import { Box, HStack } from '../elements/LayoutPrimitives'
import { StyledText, StyledImg, StyledAnchor } from '../elements/StyledText'
import { StyledText, StyledAnchor } from '../elements/StyledText'
import { TooltipWrapped } from './Tooltip'
import Link from 'next/link'
import { InstallationIcon } from './images/InstallationIcon'
const TooltipStyle = {
backgroundColor: '#F9D354',

View file

@ -2,7 +2,8 @@ import { Root, Overlay, Content } from '@radix-ui/react-dialog'
import { X } from 'phosphor-react'
import { styled, keyframes, theme } from '../tokens/stitches.config'
import { Button } from './Button'
import { HStack } from './LayoutPrimitives'
import { CloseButton } from './CloseButton'
import { HStack, SpanBox } from './LayoutPrimitives'
import { StyledText } from './StyledText'
export const ModalRoot = styled(Root, {})
@ -17,6 +18,7 @@ export const ModalOverlay = styled(Overlay, {
width: '100vw',
height: '100vh',
position: 'fixed',
zIndex: 10,
inset: 0,
'@media (prefers-reduced-motion: no-preference)': {
animation: `${overlayShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
@ -29,7 +31,6 @@ const Modal = styled(Content, {
boxShadow: theme.shadows.cardBoxShadow.toString(),
position: 'fixed',
'&:focus': { outline: 'none' },
zIndex: '1',
})
export const ModalContent = styled(Modal, {
@ -56,18 +57,12 @@ export const ModalTitleBar = (props: ModalTitleBarProps) => {
<HStack
distribution="between"
alignment="center"
css={{ height: '68px', width: '100%' }}
css={{ height: '50px', width: '100%' }}
>
<StyledText style="modalHeadline">{props.title}</StyledText>
<Button
css={{ ml: 'auto' }}
style="ghost"
onClick={() => {
props.onOpenChange(false)
}}
>
<X size={24} color={theme.colors.textNonessential.toString()} />
</Button>
<SpanBox css={{ ml: 'auto' }}>
<CloseButton close={() => props.onOpenChange(false)} />
</SpanBox>
</HStack>
)
}
@ -95,7 +90,7 @@ export const ModalButtonBar = (props: ModalButtonBarProps) => {
}}
>
<Button
style={'ctaOutlineYellow'}
style={'cancelGeneric'}
type="button"
onClick={(event) => {
event.preventDefault()

View file

@ -67,9 +67,10 @@ const textVariants = {
margin: 0,
},
modalHeadline: {
fontWeight: '500',
fontSize: '24px',
lineHeight: '1',
fontWeight: '600',
fontFamily: '$display',
fontSize: '20px',
lineHeight: '20px',
color: '$grayText',
margin: 0,
},
@ -178,6 +179,13 @@ const textVariants = {
fontWeight: 'bold',
color: 'white',
},
displaySettingsLabel: {
fontFamily: '$display',
fontWeight: '500',
fontSize: '12px',
lineHeight: '20px',
color: '$thTextSubtle2',
},
error: {
color: '$error',
fontSize: '$2',

View file

@ -1,39 +1,73 @@
import { Box, HStack } from './LayoutPrimitives'
import { styled, theme } from '../tokens/stitches.config'
import { Box, HStack, SpanBox } from './LayoutPrimitives'
import { styled } from '../tokens/stitches.config'
import { Root as Slider, Thumb, Track } from '@radix-ui/react-slider'
import { DropdownSeparator } from './DropdownElements'
type TickedRangeSliderProps = {
ticks?: number,
value: number,
onChange: (value: number) => void,
min?: number,
max?: number,
step?: number,
ticks?: number
value: number
onChange: (value: number) => void
min?: number
max?: number
step?: number
}
const Tick = styled(Box, {
background: theme.colors.grayBorderHover,
width: 2,
height: 8,
const StyledSlider = styled(Slider, {
display: 'flex',
alignItems: 'center',
'.SliderTrack': {
display: 'flex',
justifyContent: 'center',
margin: '0px',
height: '8px',
width: '225px',
borderRadius: '10px',
backgroundColor: '#F2F2F2',
},
'.SliderThumb': {
display: 'block',
width: '20px',
height: '20px',
borderRadius: '50%',
border: '4px solid white',
backgroundColor: '#FFD234',
boxShadow: '0px 0px 20px rgba(19, 56, 77, 0.2)',
},
})
export function TickedRangeSlider({
ticks = 8,
min = 10,
max = 28,
step = 1,
value,
onChange,
} : TickedRangeSliderProps
): JSX.Element {
min = 10,
max = 28,
step = 1,
value,
onChange,
}: TickedRangeSliderProps): JSX.Element {
return (
<Box css={{zIndex: 2}}>
<input onChange={(e) => onChange(e.target.value as any)} value={value} type="range" min={min} max={max} step={step} className='slider'/>
<HStack distribution='between' css={{position: 'relative', bottom: 12.2, left: 2, zIndex: -1}}>
{[...Array(ticks)].map((val, idx) => <Tick key={`ticks-${idx}`} />)}
</HStack>
</Box>
<HStack
alignment="center"
css={{
width: '225px',
height: '20px',
position: 'relative',
}}
>
<StyledSlider
max={max}
min={min}
step={step}
value={[value]}
onValueChange={(value) => {
if (value.length) {
onChange(value[0])
}
}}
>
<Track className="SliderTrack">
<Box css={{ bg: '#CECECE', width: '2.5px', height: '100%' }} />
</Track>
<Thumb className="SliderThumb" />
</StyledSlider>
</HStack>
)
}
}

View file

@ -0,0 +1,38 @@
import { config } from '../../tokens/stitches.config'
export type GridSelectorIconProps = {
color?: string
}
export function GridSelectorIcon(props: GridSelectorIconProps): JSX.Element {
const fillColor = props.color || config.theme.colors.graySolid
return (
<svg
width="21"
height="21"
viewBox="0 0 21 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M8.32487 0.170105H1.6582C0.967847 0.170105 0.408203 0.729749 0.408203 1.4201V8.08677C0.408203 8.77713 0.967847 9.33677 1.6582 9.33677H8.32487C9.01523 9.33677 9.57487 8.77713 9.57487 8.08677V1.4201C9.57487 0.729749 9.01523 0.170105 8.32487 0.170105Z"
fill={fillColor}
/>
<path
d="M19.1582 0.170105H12.4915C11.8012 0.170105 11.2415 0.729749 11.2415 1.4201V8.08677C11.2415 8.77713 11.8012 9.33677 12.4915 9.33677H19.1582C19.8486 9.33677 20.4082 8.77713 20.4082 8.08677V1.4201C20.4082 0.729749 19.8486 0.170105 19.1582 0.170105Z"
fill={fillColor}
/>
<path
d="M8.32487 11.0034H1.6582C0.967847 11.0034 0.408203 11.5631 0.408203 12.2534V18.9201C0.408203 19.6105 0.967847 20.1701 1.6582 20.1701H8.32487C9.01523 20.1701 9.57487 19.6105 9.57487 18.9201V12.2534C9.57487 11.5631 9.01523 11.0034 8.32487 11.0034Z"
fill={fillColor}
/>
<path
d="M19.1582 11.0034H12.4915C11.8012 11.0034 11.2415 11.5631 11.2415 12.2534V18.9201C11.2415 19.6105 11.8012 20.1701 12.4915 20.1701H19.1582C19.8486 20.1701 20.4082 19.6105 20.4082 18.9201V12.2534C20.4082 11.5631 19.8486 11.0034 19.1582 11.0034Z"
fill={fillColor}
/>
</g>
</svg>
)
}

View file

@ -0,0 +1,60 @@
type LineHeightDecreaseIconProps = {
strokeColor: string
}
export function LineHeightDecreaseIcon(
props: LineHeightDecreaseIconProps
): JSX.Element {
return (
<svg
width="23"
height="14"
viewBox="0 0 23 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.2728 4.04077L21.8195 4.04077"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.2728 6.92456L21.8195 6.92456"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.2728 9.80811L21.8195 9.80811"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5.20044 2.69069L3.27599 1.15112L1.35153 2.69069"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5.20044 11.1582L3.27599 12.6978L1.35153 11.1582"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.276 1.15112L3.276 12.6978"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View file

@ -0,0 +1,60 @@
type LineHeightIncreaseIconProps = {
strokeColor: string
}
export function LineHeightIncreaseIcon(
props: LineHeightIncreaseIconProps
): JSX.Element {
return (
<svg
width="23"
height="14"
viewBox="0 0 23 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.2728 2.04089L21.8195 2.04089"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.2728 6.92468L21.8195 6.92468"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.2728 11.8081L21.8195 11.8081"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5.20044 2.69069L3.27599 1.15112L1.35153 2.69069"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5.20044 11.1583L3.27599 12.6979L1.35153 11.1583"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.276 1.15112L3.276 12.6978"
stroke="#969696"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View file

@ -0,0 +1,34 @@
import { config } from '../../tokens/stitches.config'
export type ListSelectorIconProps = {
color?: string
}
export function ListSelectorIcon(props: ListSelectorIconProps): JSX.Element {
const fillColor = props.color || config.theme.colors.graySolid
return (
<svg
width="21"
height="21"
viewBox="0 0 21 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M19.1582 0.795105H1.6582C0.967847 0.795105 0.408203 1.35475 0.408203 2.0451V4.12844C0.408203 4.81879 0.967847 5.37844 1.6582 5.37844H19.1582C19.8486 5.37844 20.4082 4.81879 20.4082 4.12844V2.0451C20.4082 1.35475 19.8486 0.795105 19.1582 0.795105Z"
fill={fillColor}
/>
<path
d="M19.1582 7.87845H1.6582C0.967847 7.87845 0.408203 8.43809 0.408203 9.12845V11.2118C0.408203 11.9021 0.967847 12.4618 1.6582 12.4618H19.1582C19.8486 12.4618 20.4082 11.9021 20.4082 11.2118V9.12845C20.4082 8.43809 19.8486 7.87845 19.1582 7.87845Z"
fill={fillColor}
/>
<path
d="M19.1582 14.9618H1.6582C0.967847 14.9618 0.408203 15.5214 0.408203 16.2118V18.2951C0.408203 18.9855 0.967847 19.5451 1.6582 19.5451H19.1582C19.8486 19.5451 20.4082 18.9855 20.4082 18.2951V16.2118C20.4082 15.5214 19.8486 14.9618 19.1582 14.9618Z"
fill={fillColor}
/>
</g>
</svg>
)
}

View file

@ -0,0 +1,72 @@
import Link from 'next/link'
import { config } from '../../tokens/stitches.config'
export type OmnivoreFullLogoProps = {
color?: string
href?: string
showTitle?: boolean
}
export function OmnivoreFullLogo(props: OmnivoreFullLogoProps): JSX.Element {
const fillColor = props.color || config.theme.colors.graySolid
const href = props.href || '/home'
return (
<Link passHref href={href}>
<a
style={{
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
}}
>
<svg
width="129"
height="26"
viewBox="0 0 129 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M43.4131 13.2862C43.4131 9.75626 41.2214 7.74365 38.3606 7.74365C35.4839 7.74365 33.308 9.75626 33.308 13.2862C33.308 16.8004 35.4839 18.8288 38.3606 18.8288C41.2214 18.8288 43.4131 16.8162 43.4131 13.2862ZM41.1002 13.2862C41.1002 15.5728 40.0149 16.8109 38.3606 16.8109C36.701 16.8109 35.6209 15.5728 35.6209 13.2862C35.6209 10.9996 36.701 9.76153 38.3606 9.76153C40.0149 9.76153 41.1002 10.9996 41.1002 13.2862Z"
fill={fillColor}
/>
<path
d="M47.3285 7.89117V18.6813H49.5413V11.6319H49.6309L52.4232 18.6286H53.9301L56.7224 11.6582H56.812V18.6813H59.0248V7.89117H56.2114L53.2399 15.1408H53.1134L50.1419 7.89117H47.3285Z"
fill={fillColor}
/>
<path
d="M72.1549 7.89117H69.8842V14.6771H69.7893L65.1319 7.89117H63.1298V18.6813H65.4111V11.89H65.4901L70.1845 18.6813H72.1549V7.89117Z"
fill={fillColor}
/>
<path
d="M78.5465 7.89117H76.2652V18.6813H78.5465V7.89117Z"
fill={fillColor}
/>
<path
d="M84.5983 7.89117H82.0641L85.789 18.6813H88.7289L92.4485 7.89117H89.9196L87.3063 16.0891H87.2062L84.5983 7.89117Z"
fill={fillColor}
/>
<path
d="M105.28 13.2862C105.28 9.75626 103.088 7.74365 100.227 7.74365C97.3504 7.74365 95.1745 9.75626 95.1745 13.2862C95.1745 16.8004 97.3504 18.8288 100.227 18.8288C103.088 18.8288 105.28 16.8162 105.28 13.2862ZM102.967 13.2862C102.967 15.5728 101.881 16.8109 100.227 16.8109C98.5675 16.8109 97.4874 15.5728 97.4874 13.2862C97.4874 10.9996 98.5675 9.76153 100.227 9.76153C101.881 9.76153 102.967 10.9996 102.967 13.2862Z"
fill={fillColor}
/>
<path
d="M109.195 18.6813H111.476V14.8563H113.141L115.185 18.6813H117.704L115.412 14.4875C116.64 13.9606 117.319 12.8911 117.319 11.4159C117.319 9.27155 115.902 7.89117 113.452 7.89117H109.195V18.6813ZM111.476 13.0228V9.75626H113.015C114.332 9.75626 114.969 10.3411 114.969 11.4159C114.969 12.4854 114.332 13.0228 113.025 13.0228H111.476Z"
fill={fillColor}
/>
<path
d="M121.157 18.6813H128.449V16.8004H123.438V14.224H128.053V12.3431H123.438V9.77206H128.427V7.89117H121.157V18.6813Z"
fill={fillColor}
/>
<path
d="M8.42285 17.9061V10.5447C8.42285 9.91527 9.16173 9.55951 9.65432 9.99737L11.9257 13.3087C12.3909 13.6918 13.0477 13.6918 13.5129 13.3087L15.7296 10.0247C16.2222 9.61424 16.961 9.94263 16.961 10.5721V14.458C16.961 16.3463 18.2199 17.8788 20.1081 17.8788H20.1629C21.7775 17.8788 23.1731 16.7841 23.5563 15.2243C23.7478 14.4033 23.912 13.5549 23.912 12.8982C23.8847 6.46715 18.4388 1.596 11.9257 2.03385C6.39776 2.41698 1.9371 6.87764 1.55397 12.4056C1.11612 18.9187 6.26093 24.3645 12.7193 24.3645"
stroke={fillColor}
strokeWidth="2.18182"
strokeMiterlimit="10"
/>
</svg>
</a>
</Link>
)
}

View file

@ -20,6 +20,25 @@ type OmnivoreLogoProps = {
strokeColor: string
}
export function OmnivoreSmallLogo(props: OmnivoreLogoProps): JSX.Element {
return (
<svg
width="18"
height="19"
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.82329 12.9213V7.47867C5.82329 7.01332 6.36957 6.75029 6.73377 7.07402L8.4131 9.5222C8.75706 9.80546 9.24265 9.80546 9.58661 9.5222L11.2255 7.09425C11.5897 6.79075 12.136 7.03355 12.136 7.49891V10.372C12.136 11.768 13.0667 12.9011 14.4627 12.9011H14.5032C15.6969 12.9011 16.7288 12.0918 17.0121 10.9385C17.1537 10.3315 17.2751 9.70429 17.2751 9.2187C17.2549 4.46397 13.2285 0.862512 8.4131 1.18624C4.32605 1.4695 1.02808 4.76746 0.744823 8.85451C0.421097 13.6699 4.22489 17.6963 8.99985 17.6963"
stroke={props.strokeColor}
strokeWidth="1.4"
strokeMiterlimit="10"
/>
</svg>
)
}
export function OmnivoreLogoIcon(props: OmnivoreLogoProps): JSX.Element {
return (
<svg
@ -42,6 +61,7 @@ export function OmnivoreLogoIcon(props: OmnivoreLogoProps): JSX.Element {
export type OmnivoreNameLogoProps = {
color?: string
href?: string
showTitle?: boolean
}
export function OmnivoreNameLogo(props: OmnivoreNameLogoProps): JSX.Element {
@ -50,9 +70,22 @@ export function OmnivoreNameLogo(props: OmnivoreNameLogoProps): JSX.Element {
return (
<Link passHref href={href}>
<a style={{ textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<a
style={{
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
}}
>
<OmnivoreLogoIcon size={27} strokeColor={fillColor}></OmnivoreLogoIcon>
{/* <StyledText style="logoTitle" css={{ color: fillColor, paddingLeft: '12px' }}>Omnivore</StyledText> */}
{props.showTitle && (
<StyledText
style="logoTitle"
css={{ color: fillColor, paddingLeft: '12px' }}
>
Omnivore
</StyledText>
)}
</a>
</Link>
)

View file

@ -1,16 +1,4 @@
import { config } from '../../tokens/stitches.config'
type PenWithColorProps = {
color?: string
width?: number
height?: number
}
export function PenWithColorIcon({
color,
height,
width,
}: PenWithColorProps): JSX.Element {
export function PenWithColorIcon(): JSX.Element {
return (
<svg
width="24"

View file

@ -0,0 +1,20 @@
type SideBarProps = {
strokeColor: string
}
export function SideBarIcon(props: SideBarProps): JSX.Element {
return (
<svg
width="19"
height="14"
viewBox="0 0 19 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.10293 12.2383H7.3014V1.08129H6.10293V12.2383ZM4.62964 3.9129C4.74669 3.9129 4.84847 3.87289 4.93499 3.79286C5.0215 3.70812 5.06476 3.61633 5.06476 3.51747C5.06476 3.40919 5.0215 3.31739 4.93499 3.24207C4.84847 3.16204 4.74669 3.12203 4.62964 3.12203H2.99606C2.87901 3.12203 2.77723 3.16204 2.69071 3.24207C2.6042 3.31739 2.56094 3.40919 2.56094 3.51747C2.56094 3.61633 2.6042 3.70812 2.69071 3.79286C2.77723 3.87289 2.87901 3.9129 2.99606 3.9129H4.62964ZM4.62964 5.7418C4.74669 5.7418 4.84847 5.70179 4.93499 5.62176C5.0215 5.54173 5.06476 5.44758 5.06476 5.3393C5.06476 5.23574 5.0215 5.14629 4.93499 5.07097C4.84847 4.99094 4.74669 4.95093 4.62964 4.95093H2.99606C2.87901 4.95093 2.77723 4.99094 2.69071 5.07097C2.6042 5.14629 2.56094 5.23574 2.56094 5.3393C2.56094 5.44758 2.6042 5.54173 2.69071 5.62176C2.77723 5.70179 2.87901 5.7418 2.99606 5.7418H4.62964ZM4.62964 7.56364C4.74669 7.56364 4.84847 7.52598 4.93499 7.45066C5.0215 7.37063 5.06476 7.27883 5.06476 7.17526C5.06476 7.06699 5.0215 6.97519 4.93499 6.89987C4.84847 6.81984 4.74669 6.77982 4.62964 6.77982H2.99606C2.87901 6.77982 2.77723 6.81984 2.69071 6.89987C2.6042 6.97519 2.56094 7.06699 2.56094 7.17526C2.56094 7.27883 2.6042 7.37063 2.69071 7.45066C2.77723 7.52598 2.87901 7.56364 2.99606 7.56364H4.62964ZM2.68308 13.1562H15.8892C16.6933 13.1562 17.2938 12.9727 17.6907 12.6055C18.0877 12.243 18.2861 11.6969 18.2861 10.9672V2.34528C18.2861 1.6156 18.0877 1.06952 17.6907 0.707038C17.2938 0.339846 16.6933 0.15625 15.8892 0.15625H2.68308C1.8841 0.15625 1.28359 0.339846 0.881553 0.707038C0.484606 1.06952 0.286133 1.6156 0.286133 2.34528V10.9672C0.286133 11.6969 0.484606 12.243 0.881553 12.6055C1.28359 12.9727 1.8841 13.1562 2.68308 13.1562ZM2.69835 12.0194C2.31667 12.0194 2.02405 11.9276 1.82048 11.744C1.61692 11.5557 1.51514 11.2779 1.51514 10.9107V2.40177C1.51514 2.03458 1.61692 1.75918 1.82048 1.57559C2.02405 1.38728 2.31667 1.29313 2.69835 1.29313H15.8739C16.2505 1.29313 16.5406 1.38728 16.7441 1.57559C16.9528 1.75918 17.0571 2.03458 17.0571 2.40177V10.9107C17.0571 11.2779 16.9528 11.5557 16.7441 11.744C16.5406 11.9276 16.2505 12.0194 15.8739 12.0194H2.69835Z"
fill="#898989"
/>
</svg>
)
}

View file

@ -1,38 +0,0 @@
import { Root, Content, Anchor, Arrow } from '@radix-ui/react-popover'
import type { ReactNode } from 'react'
import { styled } from '../tokens/stitches.config'
type AnchoredPopoverProps = {
children: ReactNode
xAnchorCoordinate: number
yAnchorCoordinate: number
preventAutoFocus: boolean
}
export function AnchoredPopover(props: AnchoredPopoverProps): JSX.Element {
const StyledAnchor = styled(Anchor, {
position: 'absolute',
left: props.xAnchorCoordinate,
top: props.yAnchorCoordinate,
})
const StyledArrow = styled(Arrow, {
fill: '$grayBase',
})
return (
<Root defaultOpen modal>
<StyledAnchor />
<Content
onOpenAutoFocus={(event) => {
if (props.preventAutoFocus) {
event.preventDefault()
}
}}
>
{props.children}
<StyledArrow />
</Content>
</Root>
)
}

View file

@ -5,14 +5,13 @@ import { formattedLongDate } from './../../lib/dateFormatting'
type SubtitleStyle = 'footnote' | 'shareSubtitle'
type ArticleSubtitleProps = {
rawDisplayDate: string
href: string
author?: string
style?: SubtitleStyle
hideButton?: boolean
}
export function ArticleSubtitle(props: ArticleSubtitleProps): JSX.Element {
export function ArticleSubtitle(props: ArticleSubtitleProps): JSX.Element {
const textStyle = props.style || 'footnote'
const subtitle = articleSubtitle(props.href, props.author)
@ -20,11 +19,11 @@ export function ArticleSubtitle(props: ArticleSubtitleProps): JSX.Element {
<Box>
<StyledText style={textStyle} css={{ wordBreak: 'break-word' }}>
{subtitle}{' '}
{subtitle && (<span style={{ position: 'relative', bottom: 1 }}> </span>)}{' '}
{formattedLongDate(props.rawDisplayDate)}{' '}
{subtitle && (
<span style={{ position: 'relative', bottom: 1 }}> </span>
)}{' '}
{!props.hideButton && !shouldHideUrl(props.href) && (
<>
<span style={{ position: 'relative', bottom: 1 }}> </span>{' '}
<StyledLink
href={props.href}
target="_blank"
@ -43,6 +42,20 @@ export function ArticleSubtitle(props: ArticleSubtitleProps): JSX.Element {
)
}
type ReaderSavedInfoProps = {
rawDisplayDate: string
}
export function ReaderSavedInfo(props: ReaderSavedInfoProps): JSX.Element {
return (
<Box>
<StyledText css={{ wordBreak: 'break-word', fontSize: '15' }}>
{formattedLongDate(props.rawDisplayDate)}{' '}
</StyledText>
</Box>
)
}
function shouldHideUrl(url: string): boolean {
const origin = new URL(url).origin
const hideHosts = ['https://storage.googleapis.com', 'https://omnivore.app']

View file

@ -10,8 +10,6 @@ export type CardMenuDropdownAction =
| 'archive'
| 'unarchive'
| 'delete'
| 'share'
| 'snooze'
| 'set-labels'
| 'showOriginal'
| 'unsubscribe'
@ -22,11 +20,15 @@ type CardMenuProps = {
viewer: UserBasicData
triggerElement: ReactNode
actionHandler: (action: CardMenuDropdownAction) => void
onOpenChange?: (open: boolean) => void
}
export function CardMenu(props: CardMenuProps): JSX.Element {
return (
<Dropdown triggerElement={props.triggerElement}>
<Dropdown
triggerElement={props.triggerElement}
onOpenChange={props.onOpenChange}
>
{!props.item.isArchived ? (
<DropdownOption
onSelect={() => props.actionHandler('archive')}
@ -52,20 +54,6 @@ export function CardMenu(props: CardMenuProps): JSX.Element {
onSelect={() => props.actionHandler('editTitle')}
title="Edit Metadata"
/>
{isVipUser(props.viewer) && (
<DropdownOption
onSelect={() => {
props.actionHandler('snooze')
}}
title="Snooze"
/>
)}
{/* <DropdownOption
onSelect={() => {
props.actionHandler('share')
}}
title="Share"
/> */}
{props.item.readingProgressPercent < 98 ? (
<DropdownOption
onSelect={() => {

View file

@ -6,8 +6,6 @@ import {
import { VStack, HStack } from '../elements/LayoutPrimitives'
import { Button } from '../elements/Button'
import { StyledText } from '../elements/StyledText'
import { useConfirmListener } from '../../lib/keyboardShortcuts/useKeyboardShortcuts'
import { useEffect, useRef } from 'react'
type ConfirmationModalProps = {
message?: string

View file

@ -1,7 +1,6 @@
import { isAndroid } from '../../lib/deviceType'
import { styled, theme } from '../tokens/stitches.config'
import { AnchoredPopover } from '../patterns/AnchoredPopover'
import { StyledText } from '../elements/StyledText'
import { Button } from '../elements/Button'

View file

@ -12,21 +12,21 @@ type HighlightViewProps = {
scrollToHighlight?: (arg: string) => void
}
const StyledQuote = styled(Blockquote, {
margin: '0px 0px 0px 0px',
fontSize: '18px',
lineHeight: '27px',
color: '$grayText',
padding: '0px 16px',
borderLeft: '2px solid $omnivoreCtaYellow',
})
export function HighlightView(props: HighlightViewProps): JSX.Element {
const lines = useMemo(
() => props.highlight.quote.split('\n'),
[props.highlight.quote]
)
const StyledQuote = styled(Blockquote, {
margin: '0px 0px 0px 0px',
fontSize: '18px',
lineHeight: '27px',
color: '$grayText',
padding: '0px 16px',
borderLeft: '2px solid $omnivoreCtaYellow',
})
return (
<VStack css={{ width: '100%', boxSizing: 'border-box' }}>
<StyledQuote
@ -49,111 +49,12 @@ export function HighlightView(props: HighlightViewProps): JSX.Element {
</Fragment>
))}
</SpanBox>
<Box css={{ display: 'block', pt: '16px' }}>
{props.highlight.labels?.map(({ name, color }, index) => (
<LabelChip key={index} text={name || ''} color={color} />
))}
</Box>
</StyledQuote>
</VStack>
)
}
export function PublicHighlightView(props: HighlightViewProps): JSX.Element {
const lines = useMemo(
() => props.highlight.quote.split('\n'),
[props.highlight.quote]
)
const StyledQuote = styled(Blockquote, {
margin: '16px 0px 0px 0px',
fontSize: '18px',
lineHeight: '1.5',
fontFamily: 'Inter',
color: '$readerFont',
})
return (
<VStack css={{ width: '100%', bg: '$grayBackground', pt: '0px' }}>
<StyledQuote>
{props.highlight.prefix}
<SpanBox
css={{
bg: '$omnivoreYellow',
p: '1px',
borderRadius: '2px',
color: '#3d3d3d',
}}
>
{lines.map((line: string, index: number) => (
<Fragment key={index}>
{line}
{index !== lines.length - 1 && (
<>
<br />
<br />
</>
)}
</Fragment>
))}
</SpanBox>
{props.highlight.suffix}
</StyledQuote>
</VStack>
)
}
type HighlightFooterProps = {
site: string
author?: string
title?: string
fontSize?: string
}
export function HighlightFooter(props: HighlightFooterProps): JSX.Element {
const fontSize = props.fontSize || '12px'
return (
<VStack
alignment="start"
css={{ width: '100%', bg: '$grayBackground', pt: '0px', pb: '8px' }}
>
<StyledText
css={{
fontSize: fontSize,
fontFamily: 'Inter',
color: '$omnivoreGray',
fontWeight: '400',
mb: '0px',
p: '0px',
}}
>
From{' '}
<SpanBox
css={{
fontSize: fontSize,
color: '$omnivoreOrange',
p: '0px',
fontWeight: '400',
mb: '0px',
}}
>
{props.title}
{props.author ? `${props.author}` : null}
</SpanBox>
</StyledText>
<StyledText
css={{
pt: '0px',
mt: '0px',
fontSize: '10px',
fontFamily: 'Inter',
color: '$omnivoreGray',
fontWeight: '400',
}}
>
{props.site}
</StyledText>
<Box css={{ display: 'block', pt: '16px' }}>
{props.highlight.labels?.map(({ name, color }, index) => (
<LabelChip key={index} text={name || ''} color={color} />
))}
</Box>
</VStack>
)
}

View file

@ -11,8 +11,6 @@ export type LinkedItemCardAction =
| 'delete'
| 'mark-read'
| 'mark-unread'
| 'share'
| 'snooze'
| 'set-labels'
| 'unsubscribe'
| 'update-item'
@ -21,6 +19,5 @@ export type LinkedItemCardProps = {
item: LibraryItemNode
layout: LayoutType
viewer: UserBasicData
originText?: string
handleAction: (action: LinkedItemCardAction) => void
}

View file

@ -1,190 +0,0 @@
import { Box, VStack, HStack, SpanBox } from '../../elements/LayoutPrimitives'
import { CoverImage } from '../../elements/CoverImage'
import { StyledText } from '../../elements/StyledText'
import { authoredByText } from '../ArticleSubtitle'
import { MoreOptionsIcon } from '../../elements/images/MoreOptionsIcon'
import { theme } from '../../tokens/stitches.config'
import { CardMenu } from '../CardMenu'
import { LabelChip } from '../../elements/LabelChip'
import { ProgressBar } from '../../elements/ProgressBar'
import type { LinkedItemCardProps } from './CardTypes'
export function GridLinkedItemCard(props: LinkedItemCardProps): JSX.Element {
return (
<VStack
css={{
p: '$2',
pr: '8px',
height: '100%',
width: '100%',
maxWidth: '100%',
borderRadius: '6px',
cursor: 'pointer',
wordBreak: 'break-word',
overflow: 'clip',
border: '1px solid $grayBorder',
boxShadow: '0px 3px 11px rgba(32, 31, 29, 0.04)',
position: 'relative',
}}
alignment="start"
distribution="start"
onClick={() => {
props.handleAction('showDetail')
}}
>
<Box
css={{
position: 'absolute',
top: '1px',
left: '1px',
width: 'calc(100% - 2px)',
'& > div': {
borderRadius: '100vmax 100vmax 0 0',
},
}}
>
<ProgressBar
fillPercentage={props.item.readingProgressPercent}
fillColor={theme.colors.highlight.toString()}
backgroundColor="transparent"
borderRadius={
props.item.readingProgressPercent === 100 ? '0' : '0px 8px 8px 0px'
}
/>
</Box>
<VStack
distribution="start"
alignment="start"
css={{
px: '0px',
width: '100%',
pl: '$1',
}}
>
<HStack
alignment="start"
distribution="between"
css={{
width: '100%',
p: '0px',
mr: '-12px',
mt: '15px',
display: 'grid',
gridTemplateColumns: '1fr 24px',
gridTemplateRows: '1fr',
}}
>
<CardTitle title={props.item.title} />
<Box
css={{ alignSelf: 'end', alignItems: 'start', height: '100%' }}
onClick={(e) => {
// This is here to prevent menu click events from bubbling
// up and causing us to "click" on the link item.
e.stopPropagation()
}}
>
<CardMenu
item={props.item}
viewer={props.viewer}
triggerElement={
<MoreOptionsIcon
size={24}
strokeColor={theme.colors.grayTextContrast.toString()}
orientation="horizontal"
/>
}
actionHandler={props.handleAction}
/>
</Box>
</HStack>
<HStack alignment="start" distribution="between">
<StyledText style="caption" css={{ my: '0', mt: '-$2' }}>
{props.item.author && (
<SpanBox css={{ mr: '8px' }}>
{authoredByText(props.item.author)}
</SpanBox>
)}
<SpanBox css={{ textDecorationLine: 'underline' }}>
{props.originText}
</SpanBox>
</StyledText>
</HStack>
</VStack>
<HStack
alignment="start"
distribution="between"
css={{
width: '100%',
pt: '$2',
px: '$1',
pr: '12px',
mt: '7px',
flexGrow: '1',
}}
>
<StyledText
css={{
m: 0,
py: '0px',
mr: '$2',
fontStyle: 'normal',
fontWeight: '400',
fontSize: '14px',
lineHeight: '125%',
color: '$grayTextContrast',
flexGrow: '4',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 5,
WebkitBoxOrient: 'vertical',
}}
data-testid="listDesc"
>
{props.item.description}
</StyledText>
{props.item.image && (
<CoverImage
src={props.item.image}
alt="Link Preview Image"
width={135}
height={90}
css={{ ml: '10px', mb: '8px', borderRadius: '3px' }}
onError={(e) => {
;(e.target as HTMLElement).style.display = 'none'
}}
/>
)}
</HStack>
<Box css={{ display: 'block', mt: '8px' }}>
{props.item.labels?.map(({ name, color }, index) => (
<LabelChip key={index} text={name || ''} color={color} />
))}
</Box>
</VStack>
)
}
type CardTitleProps = {
title: string
}
function CardTitle(props: CardTitleProps): JSX.Element {
return (
<StyledText
style="listTitle"
data-testid="listTitle"
css={{
mt: '0',
mb: '0',
fontWeight: '700',
textAlign: 'left',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
width: '100%',
overflow: 'hidden',
}}
>
{props.title}
</StyledText>
)
}

View file

@ -1,88 +0,0 @@
import { styled } from '@stitches/react'
import { VStack, HStack } from '../../elements/LayoutPrimitives'
import { StyledMark, StyledText } from '../../elements/StyledText'
import { LinkedItemCardAction, LinkedItemCardProps } from './CardTypes'
export interface HighlightItemCardProps
extends Pick<LinkedItemCardProps, 'item'> {
handleAction: (action: LinkedItemCardAction) => void
}
export const PreviewImage = styled('img', {
objectFit: 'cover',
cursor: 'pointer',
})
export function HighlightItemCard(props: HighlightItemCardProps): JSX.Element {
return (
<VStack
css={{
p: '$2',
height: '100%',
width: '100%',
borderRadius: '0px',
cursor: 'pointer',
wordBreak: 'break-word',
overflow: 'clip',
border: '1px solid $grayBorder',
borderBottom: 'none',
boxShadow: '0px 3px 11px rgba(32, 31, 29, 0.04)',
bg: '$grayBg',
'&:focus': {
bg: '$grayBgActive',
},
'&:hover': {
bg: '$grayBgActive',
},
}}
alignment="start"
distribution="start"
onClick={() => {
props.handleAction('showDetail')
}}
>
<StyledText
css={{
lineHeight: '20px',
}}
>
<StyledMark
css={{
background: 'rgb($highlightBackground)',
color: '$highlightText',
fontSize: '14px',
}}
>
{props.item.quote}
</StyledMark>
</StyledText>
<HStack
css={{
marginTop: 'auto',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
{props.item.image && (
<PreviewImage
src={props.item.image}
alt="Preview Image"
width={16}
height={16}
css={{ borderRadius: '50%' }}
onError={(e) => {
;(e.target as HTMLElement).style.display = 'none'
}}
/>
)}
<StyledText
css={{
fontWeight: '700',
}}
>
{props.item.title}
</StyledText>
</HStack>
</VStack>
)
}

View file

@ -0,0 +1,107 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
export const MetaStyle = {
width: '100%',
color: '$thTextSubtle3',
fontSize: '13px',
fontWeight: '400',
fontFamily: '$display',
}
export const MenuStyle = {
display: 'flex',
marginLeft: 'auto',
height: '30px',
width: '30px',
mt: '-5px',
mr: '-5px',
pt: '2px',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '1000px',
'&:hover': {
bg: '$thBackground4',
},
}
export const TitleStyle = {
color: '$thTextContrast2',
fontSize: '16px',
fontWeight: '700',
lineHeight: '1.25',
fontFamily: '$display',
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
display: '-webkit-box',
'-webkit-line-clamp': '2',
'-webkit-box-orient': 'vertical',
}
export const DescriptionStyle = {
color: '$thTextSubtle',
pt: '10px',
fontSize: '13px',
fontWeight: '400',
lineHeight: '140%',
fontFamily: '$display',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
'-webkit-line-clamp': '2',
'-webkit-box-orient': 'vertical',
height: '45px',
alignItems: 'start',
}
export const AuthorInfoStyle = {
maxLines: '1',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '240px',
overflow: 'hidden',
height: '21px',
color: '$thTextSubtle3',
fontSize: '13px',
fontWeight: '400',
fontFamily: '$display',
}
export const timeAgo = (date: string | undefined): string => {
if (!date) {
return ''
}
return dayjs(date).fromNow()
}
const shouldHideUrl = (url: string): boolean => {
try {
const origin = new URL(url).origin
const hideHosts = ['https://storage.googleapis.com', 'https://omnivore.app']
if (hideHosts.indexOf(origin) != -1) {
return true
}
} catch {
console.log('invalid url item', url)
}
return false
}
export const siteName = (
originalArticleUrl: string,
itemUrl: string
): string => {
if (shouldHideUrl(originalArticleUrl)) {
return ''
}
try {
return new URL(originalArticleUrl).hostname.replace(/^www\./, '')
} catch {}
try {
return new URL(itemUrl).hostname.replace(/^www\./, '')
} catch {}
return ''
}

View file

@ -1,229 +1,225 @@
import { Box, VStack, HStack, SpanBox } from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { removeHTMLTags } from '../ArticleSubtitle'
import { MoreOptionsIcon } from '../../elements/images/MoreOptionsIcon'
import { theme } from '../../tokens/stitches.config'
import { CardMenu } from '../CardMenu'
import { LabelChip } from '../../elements/LabelChip'
import { ProgressBar } from '../../elements/ProgressBar'
import type { LinkedItemCardProps } from './CardTypes'
import { ProgressBarVertical } from '../../elements/ProgressBarVertical'
import { CoverImage } from '../../elements/CoverImage'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useState } from 'react'
import { DotsThreeVertical } from 'phosphor-react'
import Link from 'next/link'
import { CardMenu } from '../CardMenu'
import {
AuthorInfoStyle,
DescriptionStyle,
MenuStyle,
MetaStyle,
siteName,
timeAgo,
TitleStyle,
} from './LibraryCardStyles'
//Styles
const ellipsisText = {
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
margin: 'auto 0',
pr:'10px',
dayjs.extend(relativeTime)
type ProgressBarProps = {
fillPercentage: number
fillColor: string
backgroundColor: string
borderRadius: string
}
const cardTitleStyle = {
...ellipsisText,
width:'100%',
fontSize: '14px',
fontWeight: '600',
textAlign: 'left',
}
// Props
type CardTitleProps = {
title: string
}
// Functions
function CardTitle(
props: CardTitleProps,
): JSX.Element {
export function ProgressBar(props: ProgressBarProps): JSX.Element {
return (
<StyledText style="listTitle" data-testid="listTitle" css={cardTitleStyle}>
{props.title}
</StyledText>
<Box
css={{
height: '4px',
width: '100%',
borderRadius: '$1',
overflow: 'hidden',
backgroundColor: props.backgroundColor,
}}
>
<Box
css={{
height: '100%',
width: `${props.fillPercentage}%`,
backgroundColor: props.fillColor,
borderRadius: props.borderRadius,
}}
/>
</Box>
)
}
// Component
export function LibraryGridCard(props: LinkedItemCardProps): JSX.Element {
const [isHovered, setIsHovered] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const originText =
props.item.siteName ||
siteName(props.item.originalArticleUrl, props.item.url)
return (
<>
{props.layout === 'GRID_LAYOUT' ? (
<VStack
css={{
p: '12px',
height: '100%',
borderRadius: '8px',
cursor: 'pointer',
border: '1px solid $libraryActiveMenuItem',
mb: '15px',
}}
alignment="start"
distribution="start"
onClick={() => {
props.handleAction('showDetail')
}}
<VStack
css={{
pl: '20px',
padding: '15px',
width: '320px',
height: '100%',
minHeight: '270px',
background: 'white',
borderRadius: '5px',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: '$thBorderColor',
cursor: 'pointer',
'@smDown': {
m: '15px',
width: 'calc(100% - 30px)',
},
}}
alignment="start"
distribution="start"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
>
<Link
href={`${props.viewer.profile.username}/${props.item.slug}`}
passHref
>
<a
href={`${props.viewer.profile.username}/${props.item.slug}`}
style={{ textDecoration: 'unset', width: '100%', height: '100%' }}
>
<VStack
distribution="start"
alignment="start"
<HStack
css={{
width: '100%'
...MetaStyle,
minHeight: '35px',
}}
distribution="start"
>
<HStack
alignment="start"
distribution="between"
<Box>
{timeAgo(props.item.savedAt)}
{` `}
{props.item.wordsCount ?? 0 > 0
? `${Math.max(
1,
Math.round((props.item.wordsCount ?? 0) / 235)
)} min read`
: null}
{props.item.highlights?.length ?? 0 > 0
? `${props.item.highlights?.length} highlights`
: null}
</Box>
<Box
css={{
display: 'grid',
gridTemplateColumns: '1fr 24px',
gridTemplateRows: '1fr',
width:'100%',
...MenuStyle,
visibility: isHovered || menuOpen ? 'unset' : 'hidden',
'@media (hover: none)': {
visibility: 'unset',
},
}}
>
<CardTitle title={props.item.title} />
<Box
css={{ alignSelf: 'end', alignItems: 'end', height: '100%' }}
onClick={(e) => {
// This is here to prevent menu click events from bubbling
// up and causing us to "click" on the link item.
e.stopPropagation()
<CardMenu
item={props.item}
viewer={props.viewer}
onOpenChange={(open) => setMenuOpen(open)}
actionHandler={props.handleAction}
triggerElement={
<DotsThreeVertical size={25} weight="bold" color="#ADADAD" />
}
/>
</Box>
</HStack>
<VStack
alignment="start"
distribution="start"
css={{ height: '100%', width: '100%' }}
>
<Box
css={{
...TitleStyle,
height: '42px',
}}
>
{props.item.title}
</Box>
<Box css={DescriptionStyle}>{props.item.description}</Box>
<SpanBox
css={{
...AuthorInfoStyle,
m: '0px',
}}
>
{props.item.author}
{props.item.author && originText && ' | '}
<SpanBox css={{ textDecoration: 'underline' }}>
{originText}
</SpanBox>
</SpanBox>
<SpanBox
css={{
pt: '20px',
pr: '10px',
pb: '20px',
width: '100%',
m: '0px',
}}
>
<ProgressBar
fillPercentage={props.item.readingProgressPercent}
fillColor="$thProgressFg"
backgroundColor="$thBorderSubtle"
borderRadius="5px"
/>
</SpanBox>
<HStack
distribution="start"
alignment="start"
css={{ width: '100%', minHeight: '50px' }}
>
<HStack
css={{
display: 'block',
minHeight: '35px',
}}
>
<CardMenu
item={props.item}
viewer={props.viewer}
triggerElement={
<MoreOptionsIcon
size={24}
strokeColor={theme.colors.grayTextContrast.toString()}
orientation="horizontal"
/>
}
actionHandler={props.handleAction}
/>
</Box>
</HStack>
<HStack alignment="start" distribution="between">
<StyledText
style="caption"
css={{ fontWeight: '600'}}
{props.item.labels?.map(({ name, color }, index) => (
<LabelChip key={index} text={name || ''} color={color} />
))}
</HStack>
<VStack
css={{
width: '80px',
height: '100%',
marginLeft: 'auto',
flexGrow: '1',
}}
alignment="end"
distribution="end"
>
{props.item.author && (
<SpanBox>{removeHTMLTags(props.item.author)}</SpanBox>
{props.item.image && (
<CoverImage
src={props.item.image}
alt="Link Preview Image"
width={50}
height={50}
css={{ borderRadius: '8px' }}
onError={(e) => {
;(e.target as HTMLElement).style.display = 'none'
}}
/>
)}
{props.originText && (
<>
<Box
css={{
height: '4px',
width: '4px',
borderRadius: '50%',
display: 'inline-block',
background: 'var(--colors-graySolid)',
}}
></Box>
<SpanBox css={{ color: 'var(--colors-graySolid)' }}>
{props.originText}
</SpanBox>
</>
)}
</StyledText>
</VStack>
</HStack>
</VStack>
<HStack
alignment="start"
distribution="between"
css={{
width: '100%',
pr: '12px',
flexGrow: '1',
}}
>
<StyledText
css={{
fontStyle: 'normal',
fontWeight: '400',
fontSize: '14px',
}}
data-testid="listDesc"
>
{props.item.description}
</StyledText>
</HStack>
<Box css={{ display: 'block', py: '12px' }}>
{props.item.labels?.map(({ name, color }, index) => (
<LabelChip key={index} text={name || ''} color={color} />
))}
</Box>
<ProgressBar
fillPercentage={props.item.readingProgressPercent}
fillColor={theme.colors.highlight.toString()}
backgroundColor={theme.colors.lightBorder.toString()}
borderRadius={
props.item.readingProgressPercent === 100
? '0'
: '0px 8px 8px 0px'
}
/>
</VStack>
) : (
// ELSE display List Layout
<VStack
distribution="start"
alignment="start"
css={{
width: '100%',
}}
>
<HStack
alignment="start"
distribution="between"
css={{
width: '100%',
display: 'grid',
gridTemplateColumns: '0.01fr 1fr 2fr 150px',
gridTemplateRows: '1fr',
borderBottom: '1px solid $graySeparator',
height: '45px',
}}
>
<ProgressBarVertical
fillPercentage={props.item.readingProgressPercent}
fillColor={theme.colors.highlight.toString()}
backgroundColor={theme.colors.grayProgressBackground.toString()}
borderRadius={
props.item.readingProgressPercent === 100
? '0'
: '0px 8px 8px 0px'
}
height={'45px'}
/>
<CardTitle title={props.item.title} />
<StyledText
css={{
...ellipsisText,
color: '$grayTextContrast',
}}
data-testid="listDesc"
>
{props.item.labels?.map(({ name, color }, index) => (
<LabelChip key={index} text={name || ''} color={color} />
))}
{props.item.description}
</StyledText>
<StyledText
style="caption"
css={{ ...ellipsisText, fontWeight: '400' }}
>
{props.item.author && (
<SpanBox>{removeHTMLTags(props.item.author)}</SpanBox>
)}
</StyledText>
</HStack>
</VStack>
)}
</>
</a>
</Link>
</VStack>
)
}

View file

@ -0,0 +1,187 @@
import { Box, VStack, HStack } from '../../elements/LayoutPrimitives'
import { useMemo, useState } from 'react'
import { CaretDown, CaretUp } from 'phosphor-react'
import { MetaStyle, timeAgo, TitleStyle } from './LibraryCardStyles'
import { styled } from '@stitches/react'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { LibraryItemNode } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { Button } from '../../elements/Button'
import { theme } from '../../tokens/stitches.config'
import { HighlightItem } from '../../templates/homeFeed/HighlightItem'
import { getHighlightLocation } from '../../templates/article/NotebookModal'
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
export const GridSeparator = styled(Box, {
height: '1px',
marginTop: '15px',
backgroundColor: '$thBorderColor',
})
type LibraryHighlightGridCardProps = {
viewer: UserBasicData
item: LibraryItemNode
}
export function LibraryHighlightGridCard(
props: LibraryHighlightGridCardProps
): JSX.Element {
const [expanded, setExpanded] = useState(false)
const higlightCount = props.item.highlights?.length ?? 0
const sortedHighlights = useMemo(() => {
const sorted = (a: number, b: number) => {
if (a < b) {
return -1
}
if (a > b) {
return 1
}
return 0
}
if (!props.item.highlights) {
return []
}
return props.item.highlights.sort((a: Highlight, b: Highlight) => {
if (a.highlightPositionPercent && b.highlightPositionPercent) {
return sorted(a.highlightPositionPercent, b.highlightPositionPercent)
}
// We do this in a try/catch because it might be an invalid diff
// With PDF it will definitely be an invalid diff.
try {
const aPos = getHighlightLocation(a.patch)
const bPos = getHighlightLocation(b.patch)
if (aPos && bPos) {
return sorted(aPos, bPos)
}
} catch {}
return a.createdAt.localeCompare(b.createdAt)
})
}, [props.item.highlights])
return (
<VStack
css={{
pl: '15px',
padding: '15px',
width: '100%',
height: '100%',
marginTop: '20px',
background: 'white',
borderRadius: '5px',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: '$thBorderColor',
cursor: 'pointer',
bg: '$thBackground3',
}}
alignment="start"
distribution="start"
>
{!expanded && (
<HStack
css={{
...MetaStyle,
minHeight: '35px',
}}
distribution="start"
>
<Box>
{timeAgo(props.item.savedAt)}
{` `}
{props.item.wordsCount ?? 0 > 0
? `${Math.max(
1,
Math.round((props.item.wordsCount ?? 0) / 235)
)} min read`
: null}
{props.item.highlights?.length ?? 0 > 0
? `${props.item.highlights?.length} highlights`
: null}
</Box>
</HStack>
)}
<VStack
alignment="start"
distribution="start"
css={{ height: '100%', width: '100%' }}
>
<Box
css={{
...TitleStyle,
}}
>
{props.item.title}
</Box>
{expanded && (
<>
<GridSeparator css={{ width: '100%' }} />
<VStack
css={{ height: '100%', width: '100%' }}
distribution="start"
>
{sortedHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}
viewer={props.viewer}
item={props.item}
highlight={highlight}
/>
))}
</VStack>
</>
)}
<Box css={{ width: '100%' }}>
{!expanded ? (
<Button
css={{
mt: '30px',
display: 'flex',
gap: '5px',
px: '10px',
py: '6px',
fontSize: '11px',
fontFamily: '$inter',
fontWeight: '500',
bg: '$thBackground2',
color: '$thTextSubtle2',
alignItems: 'center',
border: '1px solid transparent',
}}
onClick={(event) => {
setExpanded(true)
event.preventDefault()
}}
>
{`View ${higlightCount} highlight${higlightCount > 1 ? 's' : ''}`}
<CaretDown
size={10}
weight="bold"
color={theme.colors.thHighContrast.toString()}
/>
</Button>
) : (
<Button
style="plainIcon"
css={{
width: '25px',
height: '25px',
bg: '$thBackground2',
pt: '2px',
borderRadius: '1000px',
}}
onClick={(event) => {
setExpanded(false)
event.preventDefault()
}}
>
<CaretUp size={15} weight="bold" color="#EC6A5E" />
</Button>
)}
</Box>
</VStack>
</VStack>
)
}

View file

@ -0,0 +1,146 @@
import { Box, VStack, HStack, SpanBox } from '../../elements/LayoutPrimitives'
import { LabelChip } from '../../elements/LabelChip'
import type { LinkedItemCardProps } from './CardTypes'
import { useState } from 'react'
import { DotsThree } from 'phosphor-react'
import Link from 'next/link'
import { CardMenu } from '../CardMenu'
import {
AuthorInfoStyle,
MenuStyle,
MetaStyle,
siteName,
timeAgo,
TitleStyle,
} from './LibraryCardStyles'
export function LibraryListCard(props: LinkedItemCardProps): JSX.Element {
const [isHovered, setIsHovered] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const originText =
props.item.siteName ||
siteName(props.item.originalArticleUrl, props.item.url)
return (
<VStack
css={{
p: '20px',
height: '100%',
cursor: 'pointer',
gap: '10px',
border: '1px solid $grayBorder',
borderBottom: 'none',
width: '900px',
'@xxl': {
width: '1200px',
},
'@xlgDown': {
width: 'unset',
borderRadius: 'unset',
borderLeft: 'unset',
borderRight: 'unset',
},
}}
alignment="start"
distribution="start"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
>
<Link
href={`${props.viewer.profile.username}/${props.item.slug}`}
passHref
>
<a
href={`${props.viewer.profile.username}/${props.item.slug}`}
style={{ textDecoration: 'unset', width: '100%', height: '100%' }}
>
<HStack css={MetaStyle} distribution="start">
<Box>
{timeAgo(props.item.savedAt)}
{` `}
{props.item.wordsCount ?? 0 > 0
? `${Math.max(
1,
Math.round((props.item.wordsCount ?? 0) / 235)
)} min read`
: null}
{props.item.readingProgressPercent ?? 0 > 0 ? (
<>
{``}
<SpanBox css={{ color: '#55B938' }}>
{`${Math.round(props.item.readingProgressPercent)}%`}
</SpanBox>
</>
) : null}
{props.item.highlights?.length ?? 0 > 0
? `${props.item.highlights?.length} highlights`
: null}
</Box>
<Box
css={{
...MenuStyle,
visibility: isHovered || menuOpen ? 'unset' : 'hidden',
'@media (hover: none)': {
visibility: 'unset',
},
}}
>
<CardMenu
item={props.item}
viewer={props.viewer}
onOpenChange={(open) => setMenuOpen(open)}
actionHandler={props.handleAction}
triggerElement={
<DotsThree size={25} weight="bold" color="#ADADAD" />
}
/>
</Box>
</HStack>
<VStack
alignment="start"
distribution="start"
css={{ height: '100%', width: '100%' }}
>
<Box css={{ ...TitleStyle, width: '80%' }}>{props.item.title}</Box>
<SpanBox
css={{
mt: '5px',
...AuthorInfoStyle,
}}
>
{props.item.author}
{props.item.author && originText && ' | '}
<SpanBox css={{ textDecoration: 'underline' }}>
{originText}
</SpanBox>
</SpanBox>
<HStack
distribution="start"
alignment="start"
// The two pixels here are to account for the label margin
css={{ width: '100%', ml: '-2px', mt: '5px' }}
>
<HStack
css={{
display: 'block',
}}
>
{props.item.labels?.map(({ name, color }, index) => (
<LabelChip key={index} text={name || ''} color={color} />
))}
</HStack>
</HStack>
</VStack>
</a>
</Link>
</VStack>
)
}

View file

@ -1,46 +1,11 @@
import { GridLinkedItemCard } from './GridLinkedItemCard'
import { ListLinkedItemCard } from './ListLinkedItemCard'
import type { LinkedItemCardProps } from './CardTypes'
import { HighlightItemCard } from './HighlightItemCard'
import { PageType } from '../../../lib/networking/fragments/articleFragment'
const shouldHideUrl = (url: string): boolean => {
try {
const origin = new URL(url).origin
const hideHosts = ['https://storage.googleapis.com', 'https://omnivore.app']
if (hideHosts.indexOf(origin) != -1) {
return true
}
} catch {
console.log('invalid url item', url)
}
return false
}
const siteName = (originalArticleUrl: string, itemUrl: string): string => {
if (shouldHideUrl(originalArticleUrl)) {
return ''
}
try {
return new URL(originalArticleUrl).hostname.replace(/^www\./, '')
} catch {}
try {
return new URL(itemUrl).hostname.replace(/^www\./, '')
} catch {}
return ''
}
import { LibraryGridCard } from './LibraryGridCard'
import { LibraryListCard } from './LibraryListCard'
export function LinkedItemCard(props: LinkedItemCardProps): JSX.Element {
const originText =
props.item.siteName ||
siteName(props.item.originalArticleUrl, props.item.url)
if (props.item.pageType === PageType.HIGHLIGHTS) {
return <HighlightItemCard {...props} />
}
if (props.layout == 'LIST_LAYOUT') {
return <ListLinkedItemCard {...props} originText={originText} />
return <LibraryListCard {...props} />
} else {
return <GridLinkedItemCard {...props} originText={originText} />
return <LibraryGridCard {...props} />
}
}

View file

@ -1,215 +0,0 @@
import {
Box,
HStack,
VStack,
MediumBreakpointBox,
} from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { authoredByText } from '../ArticleSubtitle'
import { MoreOptionsIcon } from '../../elements/images/MoreOptionsIcon'
import { theme } from '../../tokens/stitches.config'
import { CardMenu } from '../CardMenu'
import type { LinkedItemCardProps } from './CardTypes'
import { ProgressBar } from '../../elements/ProgressBar'
export function ListLinkedItemCard(props: LinkedItemCardProps): JSX.Element {
return (
<MediumBreakpointBox
smallerLayoutNode={<ListLinkedItemCardNarrow {...props} />}
largerLayoutNode={<ListLinkedItemCardWide {...props} />}
/>
)
}
export function ListLinkedItemCardNarrow(
props: LinkedItemCardProps
): JSX.Element {
return (
<Box
css={{
p: '$3',
height: '100%',
width: '100%',
maxWidth: '100%',
borderRadius: 0,
cursor: 'pointer',
wordBreak: 'break-word',
border: '1px solid $grayBorder',
borderBottom: 'none',
alignItems: 'center',
display: 'grid',
gridTemplateColumns: '1fr 24px',
gridTemplateRows: '1fr',
}}
onClick={() => {
props.handleAction('showDetail')
}}
>
<HStack
distribution="start"
alignment="end"
css={{
px: '$2',
pl: '0px',
}}
>
<VStack>
<StyledText
style="listTitle"
data-testid="listTitle"
css={{
mt: '0px',
mb: '$1',
textAlign: 'left',
lineHeight: 'normal',
}}
>
{props.item.title}
</StyledText>
<HStack>
{props.item.author && (
<StyledText style="caption" css={{ my: '$1' }}>
{authoredByText(props.item.author)}
</StyledText>
)}
<StyledText
style="caption"
css={{
my: '$1',
ml: props.item.author ? '8px' : 0,
textDecorationLine: 'underline',
}}
>
{props.originText}
</StyledText>
</HStack>
</VStack>
</HStack>
<Box
css={{
alignSelf: 'end',
alignItems: 'center',
display: 'grid',
placeItems: 'center',
}}
onClick={(e) => {
// This is here to prevent menu click events from bubbling
// up and causing us to "click" on the link item.
e.stopPropagation()
}}
>
<CardMenu
item={props.item}
viewer={props.viewer}
triggerElement={
<MoreOptionsIcon
size={24}
strokeColor={theme.colors.grayTextContrast.toString()}
orientation="horizontal"
/>
}
actionHandler={props.handleAction}
/>
</Box>
</Box>
)
}
export function ListLinkedItemCardWide(
props: LinkedItemCardProps
): JSX.Element {
return (
<HStack
css={{
p: '$3',
height: '100%',
width: '100%',
maxWidth: '100%',
borderRadius: 0,
cursor: 'pointer',
wordBreak: 'break-word',
border: '1px solid $grayBorder',
borderBottom: 'none',
alignItems: 'center',
}}
onClick={() => {
props.handleAction('showDetail')
}}
>
<HStack
distribution="start"
alignment="end"
css={{
px: '$2',
flexGrow: 1,
pl: '0px',
}}
>
<StyledText
style="listTitle"
data-testid="listTitle"
css={{ mt: '0px', mb: '$1', textAlign: 'left', lineHeight: 'normal' }}
>
{props.item.title}
</StyledText>
{props.item.author && (
<StyledText style="caption" css={{ my: '$1', ml: '8px' }}>
{authoredByText(props.item.author)}
</StyledText>
)}
<StyledText
style="caption"
css={{ my: '$1', ml: '8px', textDecorationLine: 'underline' }}
>
{props.originText}
</StyledText>
</HStack>
<Box
css={{
width: '40px',
height: '8px',
mr: '$2',
backgroundColor: '$grayBase',
display: 'grid',
placeItems: 'center',
borderRadius: '6px',
border: '1px solid $grayBorder',
px: '1px',
}}
>
<ProgressBar
fillPercentage={props.item.readingProgressPercent}
fillColor={theme.colors.highlight.toString()}
backgroundColor={theme.colors.grayTextContrast.toString()}
borderRadius={'8px'}
/>
</Box>
<Box
css={{
alignSelf: 'end',
alignItems: 'center',
display: 'grid',
placeItems: 'center',
}}
onClick={(e) => {
// This is here to prevent menu click events from bubbling
// up and causing us to "click" on the link item.
e.stopPropagation()
}}
>
<CardMenu
item={props.item}
viewer={props.viewer}
triggerElement={
<MoreOptionsIcon
size={24}
strokeColor={theme.colors.grayTextContrast.toString()}
orientation="horizontal"
/>
}
actionHandler={props.handleAction}
/>
</Box>
</HStack>
)
}

View file

@ -4,12 +4,9 @@ import { DropdownMenu, HeaderDropdownAction } from './../patterns/DropdownMenu'
import { updateTheme } from '../../lib/themeUpdater'
import { AvatarDropdown } from './../elements/AvatarDropdown'
import { ThemeId } from './../tokens/stitches.config'
import { useCallback, useEffect, useState } from 'react'
import { useState } from 'react'
import { useRouter } from 'next/router'
import { useKeyboardShortcuts } from '../../lib/keyboardShortcuts/useKeyboardShortcuts'
import { primaryCommands } from '../../lib/keyboardShortcuts/navigationShortcuts'
import { UserBasicData } from '../../lib/networking/queries/useGetViewerQuery'
import { setupAnalytics } from '../../lib/analytics'
type HeaderProps = {
user?: UserBasicData
@ -27,34 +24,6 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
const router = useRouter()
const [isScrolled, setIsScrolled] = useState(false)
useKeyboardShortcuts(
primaryCommands((action) => {
switch (action) {
// case 'themeDarker':
// darkenTheme()
// break
// case 'themeLighter':
// lightenTheme()
// break
case 'toggleShortcutHelpModalDisplay':
props.setShowKeyboardCommandsModal(true)
break
}
})
)
const initAnalytics = useCallback(() => {
setupAnalytics(props.user)
}, [props.user])
useEffect(() => {
initAnalytics()
window.addEventListener('load', initAnalytics)
return () => {
window.removeEventListener('load', initAnalytics)
}
}, [initAnalytics])
function headerDropdownActionHandler(action: HeaderDropdownAction): void {
switch (action) {
case 'apply-darker-theme':
@ -108,12 +77,14 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
return (
<>
<SpanBox css={{
display: props.alwaysDisplayToolbar ? 'none' : 'flex',
'@lgDown': {
display: 'none',
}
}}>
<SpanBox
css={{
display: props.alwaysDisplayToolbar ? 'none' : 'flex',
'@lgDown': {
display: 'none',
},
}}
>
<FloatingNavHeader
{...props}
isVisible={true}
@ -124,11 +95,13 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
alwaysDisplayToolbar={props.alwaysDisplayToolbar}
/>
</SpanBox>
<SpanBox css={{
'@lg': {
display: props.alwaysDisplayToolbar ? 'flex' : 'none',
}
}}>
<SpanBox
css={{
'@lg': {
display: props.alwaysDisplayToolbar ? 'flex' : 'none',
},
}}
>
<NavHeader
{...props}
isVisible={true}
@ -216,10 +189,7 @@ function NavHeader(props: NavHeaderProps): JSX.Element {
<DropdownMenu
username={props.username}
triggerElement={
<AvatarDropdown
userInitials={props.userInitials}
profileImageURL={props.profileImageURL}
/>
<AvatarDropdown userInitials={props.userInitials} />
}
actionHandler={props.actionHandler}
/>
@ -241,7 +211,7 @@ function FloatingNavHeader(props: NavHeaderProps): JSX.Element {
position: 'fixed',
display: 'flex',
alignItems: 'center',
zIndex: 100,
zIndex: 5,
}}
>
<OmnivoreNameLogo href={props.username ? '/home' : '/login'} />
@ -256,16 +226,13 @@ function FloatingNavHeader(props: NavHeaderProps): JSX.Element {
right: '18px',
position: 'fixed',
display: 'flex',
alignItems: 'center'
alignItems: 'center',
}}
>
<DropdownMenu
username={props.username}
triggerElement={
<AvatarDropdown
userInitials={props.userInitials}
profileImageURL={props.profileImageURL}
/>
<AvatarDropdown userInitials={props.userInitials} />
}
actionHandler={props.actionHandler}
/>
@ -274,4 +241,3 @@ function FloatingNavHeader(props: NavHeaderProps): JSX.Element {
</>
)
}

View file

@ -0,0 +1,39 @@
import { ReactNode } from 'react'
import {
Dropdown,
DropdownOption,
DropdownSeparator,
} from '../elements/DropdownElements'
type DropdownMenuProps = {
triggerElement: ReactNode
articleActionHandler: (action: string, arg?: unknown) => void
}
export function ReaderDropdownMenu(props: DropdownMenuProps): JSX.Element {
return (
<Dropdown triggerElement={props.triggerElement}>
<DropdownOption
onSelect={() => props.articleActionHandler('archive')}
title="Archive"
/>
<DropdownOption
onSelect={() => props.articleActionHandler('editLabels')}
title="Edit Labels"
/>
<DropdownOption
onSelect={() => props.articleActionHandler('showEditModal')}
title="Edit Info"
/>
<DropdownOption
onSelect={() => props.articleActionHandler('delete')}
title="Delete"
/>
<DropdownSeparator />
<DropdownOption
onSelect={() => window.Intercom('show')}
title="Feedback"
/>
</Dropdown>
)
}

View file

@ -1,54 +0,0 @@
import { Box, VStack, HStack } from '../elements/LayoutPrimitives'
import { StyledText } from '../elements/StyledText'
import { CoverImage } from '../elements/CoverImage'
import { ArticleSubtitle } from './ArticleSubtitle'
type ShareArticleViewProps = {
url: string
title: string
imageURL?: string
author?: string
description?: string
originalArticleUrl: string
publishedAt: string
}
export function ShareArticleView(props: ShareArticleViewProps): JSX.Element {
return (
<HStack
alignment='start'
distribution='start'
css={{
background: 'white',
width: '100%',
p: '10px 0px 10px 0px',
boxShadow: 'rgb(120 123 134 / 12%) 0px 4px 18px'
}}>
{props.imageURL && (
<CoverImage
src={props.imageURL}
alt='Preview Image'
width={120}
height={120}
onError={(e) => {
(e.target as HTMLElement).style.display = 'none'
}}
/>
)}
<VStack >
<Box css={{ pl: '16px', width: '100%', color: 'black', }}>
<StyledText style='shareTitle'>{props.title}</StyledText>
<ArticleSubtitle
style='shareSubtitle'
hideButton={true}
author={props.author}
rawDisplayDate={props.publishedAt}
href={props.originalArticleUrl}
/>
</Box>
</VStack>
</HStack>
)
}

View file

@ -1,217 +0,0 @@
import { useRouter } from 'next/router'
import { PublicArticleAttributes } from '../../lib/networking/queries/useGetPublicArticleQuery'
import { PrimaryLayout } from './PrimaryLayout'
import { StyledText } from '../elements/StyledText'
import { VStack, HStack, Box } from '../elements/LayoutPrimitives'
import { authoredByText } from '../patterns/ArticleSubtitle'
import Image from 'next/image'
import { HighlightFooter, PublicHighlightView } from '../patterns/HighlightView'
import { useMemo, useRef, useState } from 'react'
import { Highlight } from '../../lib/networking/fragments/highlightFragment'
import { Button } from '../elements/Button'
type ArticleHighlightsProps = {
publicArticle: PublicArticleAttributes
showAllHighlights: boolean
selectedHighlightId?: string
previewImagePath?: string
}
export function ArticleHighlights(props: ArticleHighlightsProps): JSX.Element {
const router = useRouter()
return (
<PrimaryLayout
pageMetaDataProps={{
title: props.publicArticle.title,
description: props.publicArticle.description,
path: router.pathname,
ogImage: props.previewImagePath,
ogImageType: 'image/png',
}}
pageTestId={router.pathname}
>
<VStack alignment="center" css={{ width: '100%' }}>
<LoadedContent
publicArticle={props.publicArticle}
showAllHighlights={props.showAllHighlights}
selectedHighlightId={props.selectedHighlightId}
/>
</VStack>
</PrimaryLayout>
)
}
type LoadedContentProps = {
publicArticle: PublicArticleAttributes
showAllHighlights: boolean
selectedHighlightId?: string
}
function LoadedContent(props: LoadedContentProps): JSX.Element {
const router = useRouter()
const container = useRef<HTMLDivElement>(null)
const [showAllHighlights, setShowAllHighlights] = useState(
props.showAllHighlights
)
const selectedHighlights =
props.publicArticle.highlights.filter((highlight) => {
return highlight.shortId === props.selectedHighlightId
}) ?? ([] as Highlight[])
const unselectedHighlights =
props.publicArticle.highlights.filter((highlight) => {
return highlight.shortId !== props.selectedHighlightId
}) ?? ([] as Highlight[])
const moreHighlightsCount = useMemo(() => {
return props.publicArticle.highlights.length - 1
}, [props.publicArticle.highlights])
// const sharedBy = useMemo(() => {
// if (moreHighlightsCount < 1) return undefined
// return props.publicArticle.highlights[0].user
// }, [moreHighlightsCount, props.publicArticle.highlights])
const articleSite = useMemo(() => {
try {
const url = new URL(props.publicArticle.url)
return url.hostname
} catch (e) {
console.log('error ', e)
return ''
}
}, [props.publicArticle.url])
return (
<VStack
alignment="center"
css={{
p: props.selectedHighlightId ? '66px $3 0px $3' : '0px $3 0px $3',
width: '66%',
maxWidth: '600px',
mb: '$4',
'@smDown': {
p: '32px $3 0px $3',
width: '100%',
},
aspectRatio: router.query.adjustAspectRatio as string,
}}
ref={container}
>
{!props.selectedHighlightId && (
<LinkedItem publicArticle={props.publicArticle} />
)}
{selectedHighlights.map((highlight) => (
<PublicHighlightView
key={highlight.id}
highlight={highlight}
title={props.publicArticle.title}
author={props.publicArticle.author}
/>
))}
{showAllHighlights &&
unselectedHighlights.map((highlight) => (
<PublicHighlightView key={highlight.id} highlight={highlight} />
))}
<HighlightFooter
title={props.publicArticle.title}
author={props.publicArticle.author}
site={articleSite}
/>
{!showAllHighlights && moreHighlightsCount > 0 && (
<Button onClick={() => setShowAllHighlights(true)}>
<Box
css={{ width: '8px', color: '$grayBackground', mr: '4px' }}
className="dropdown-arrow"
/>
Read {moreHighlightsCount} more highlight
{moreHighlightsCount > 1 ? 's ' : ' '}
{/* from {sharedBy.name} */}
</Button>
)}
<Button
style="ctaDarkYellow"
css={{ m: '44px', p: '14px 16px', alignSelf: 'center' }}
onClick={() => {
if (window !== undefined) {
window.open(props.publicArticle.url, '_blank')
}
}}
>
Read Original
</Button>
</VStack>
)
}
type LinkedItemProps = {
publicArticle: PublicArticleAttributes
}
function LinkedItem(props: LinkedItemProps): JSX.Element {
const originText = new URL(props.publicArticle.url).hostname
return (
<VStack
alignment="start"
css={{
bg: '$grayBgActive',
borderRadius: '$2',
p: '$2',
width: '100%',
}}
>
<HStack distribution="start" css={{ width: '100%' }}>
<HStack distribution="start" alignment="start">
<Box>
<Image
src={
'/static/images/linked-item-card-placeholder.png'
// props.image ?? '/static/images/linked-item-card-placeholder.png'
}
alt="Link Preview Image"
layout="fixed"
width={88}
height={88}
/>
</Box>
<VStack
distribution="start"
alignment="start"
css={{ px: '$2', flexGrow: 1 }}
>
<StyledText
style="caption"
css={{ my: '$1', textAlign: 'left', overflow: 'auto' }}
>
{props.publicArticle.title}
</StyledText>
{props.publicArticle.author && (
<StyledText style="caption" css={{ my: '$1' }}>
{authoredByText(props.publicArticle.author)}
</StyledText>
)}
<StyledText style="caption" css={{ my: '$1' }}>
{originText}
</StyledText>
</VStack>
</HStack>
</HStack>
<StyledText
style="caption"
css={{ m: 0, py: '$2', height: '5em', overflow: 'hidden' }}
>
{props.publicArticle.description}
</StyledText>
</VStack>
)
}

View file

@ -16,9 +16,7 @@ import {
primaryCommands,
libraryListCommands,
highlightBarKeyboardCommands,
articleKeyboardCommands,
} from '../../lib/keyboardShortcuts/navigationShortcuts'
import { useRouter } from 'next/router'
type KeyboardShortcutListModalProps = {
onOpenChange: (open: boolean) => void
@ -27,8 +25,6 @@ type KeyboardShortcutListModalProps = {
export function KeyboardShortcutListModal(
props: KeyboardShortcutListModalProps
): JSX.Element {
const router = useRouter()
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
<ModalOverlay />
@ -72,10 +68,6 @@ export function KeyboardShortcutListModal(
libraryListCommands(() => {})
)}
/>
<ShortcutListSection
title="Article"
commands={articleKeyboardCommands(router, () => {})}
/>
<ShortcutListSection
title="Highlight Bar"
commands={highlightBarKeyboardCommands(() => {})}

View file

@ -7,7 +7,6 @@ import {
gauthRedirectURI,
appleAuthRedirectURI,
} from '../../lib/appConfig'
import AppleLogin from 'react-apple-login'
import { AppleIdButton } from './auth/AppleIdButton'
export type LoginFormProps = {

View file

@ -81,16 +81,12 @@ function MediumLoginLayout(props: LoginFormProps) {
>
<LoginForm {...props} />
</Box>
<OmnivoreIllustration isLargeLayout={true} />
<OmnivoreIllustration />
</HStack>
)
}
type OmnivoreIllustrationProps = {
isLargeLayout?: boolean
}
function OmnivoreIllustration({ isLargeLayout }: OmnivoreIllustrationProps) {
function OmnivoreIllustration() {
return (
<Box
css={{

View file

@ -0,0 +1,314 @@
import { useRouter } from 'next/router'
import { Moon, Sun } from 'phosphor-react'
import { ReactNode, useCallback } from 'react'
import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery'
import { currentTheme, updateTheme } from '../../lib/themeUpdater'
import { Avatar } from '../elements/Avatar'
import { AvatarDropdown } from '../elements/AvatarDropdown'
import {
Dropdown,
DropdownOption,
DropdownSeparator,
} from '../elements/DropdownElements'
import GridLayoutIcon from '../elements/images/GridLayoutIcon'
import ListLayoutIcon from '../elements/images/ListLayoutIcon'
import { Box, HStack, VStack } from '../elements/LayoutPrimitives'
import { StyledText } from '../elements/StyledText'
import { styled, theme, ThemeId } from '../tokens/stitches.config'
import { LayoutType } from './homeFeed/HomeFeedContainer'
type PrimaryDropdownProps = {
children?: ReactNode
showThemeSection: boolean
layout?: LayoutType
updateLayout?: (layout: LayoutType) => void
}
export type HeaderDropdownAction =
| 'navigate-to-install'
| 'navigate-to-emails'
| 'navigate-to-labels'
| 'navigate-to-profile'
| 'navigate-to-subscriptions'
| 'navigate-to-api'
| 'navigate-to-integrations'
| 'increaseFontSize'
| 'decreaseFontSize'
| 'logout'
export function PrimaryDropdown(props: PrimaryDropdownProps): JSX.Element {
const { viewerData } = useGetViewerQuery()
const router = useRouter()
const headerDropdownActionHandler = useCallback(
(action: HeaderDropdownAction) => {
switch (action) {
case 'navigate-to-install':
router.push('/settings/installation')
break
case 'navigate-to-emails':
router.push('/settings/emails')
break
case 'navigate-to-labels':
router.push('/settings/labels')
break
case 'navigate-to-subscriptions':
router.push('/settings/subscriptions')
break
case 'navigate-to-api':
router.push('/settings/api')
break
case 'navigate-to-integrations':
router.push('/settings/integrations')
break
case 'logout':
document.dispatchEvent(new Event('logout'))
break
default:
break
}
},
[router]
)
if (!viewerData?.me) {
return <></>
}
return (
<Dropdown
triggerElement={
props.children ?? (
<AvatarDropdown userInitials={viewerData?.me?.name.charAt(0) ?? ''} />
)
}
css={{ width: '240px' }}
>
<HStack
alignment="center"
distribution="start"
css={{
width: '100%',
height: '64px',
p: '15px',
gap: '15px',
}}
>
<Avatar
imageURL={viewerData.me.profile.pictureUrl}
height="40px"
fallbackText={viewerData?.me?.name.charAt(0) ?? ''}
/>
<VStack
css={{ height: '40px', maxWidth: '240px' }}
alignment="start"
distribution="around"
>
{viewerData.me && (
<>
<StyledText
css={{
fontSize: '14px',
fontWeight: '500',
color: '$thTextContrast2',
m: '0px',
p: '0px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{viewerData.me.name}
</StyledText>
<StyledText
css={{
fontSize: '14px',
fontWeight: '400',
color: '#898989',
m: '0px',
p: '0px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{`@${viewerData.me.profile.username}`}
</StyledText>
</>
)}
</VStack>
</HStack>
<DropdownSeparator />
{props.showThemeSection && <ThemeSection {...props} />}
<DropdownOption
onSelect={() => headerDropdownActionHandler('navigate-to-install')}
title="Install"
/>
<DropdownOption
onSelect={() => headerDropdownActionHandler('navigate-to-emails')}
title="Emails"
/>
<DropdownOption
onSelect={() => headerDropdownActionHandler('navigate-to-labels')}
title="Labels"
/>
<DropdownOption
onSelect={() => headerDropdownActionHandler('navigate-to-api')}
title="API Keys"
/>
<DropdownOption
onSelect={() => headerDropdownActionHandler('navigate-to-integrations')}
title="Integrations"
/>
<DropdownOption
onSelect={() => window.open('https://docs.omnivore.app', '_blank')}
title="Documentation"
/>
<DropdownOption
onSelect={() => window.Intercom('show')}
title="Feedback"
/>
<DropdownSeparator />
<DropdownOption
onSelect={() => headerDropdownActionHandler('logout')}
title="Logout"
/>
</Dropdown>
)
}
const StyledToggleButton = styled('button', {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '$thTextContrast2',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
width: '70px',
height: '100%',
borderRadius: '5px',
fontSize: '12px',
fontFamily: '$inter',
gap: '5px',
m: '2px',
'&:hover': {
opacity: 0.8,
},
'&[data-state="on"]': {
bg: '$thBackground',
},
})
function ThemeSection(props: PrimaryDropdownProps): JSX.Element {
return (
<>
<VStack>
<HStack
alignment="center"
css={{
width: '100%',
px: '15px',
justifyContent: 'space-between',
}}
>
<StyledText
css={{
fontSize: '14px',
fontWeight: '400',
cursor: 'default',
color: '$utilityTextDefault',
}}
>
Mode
</StyledText>
<Box
css={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: '$thBackground4',
borderRadius: '5px',
height: '34px',
p: '3px',
px: '1px',
}}
>
<StyledToggleButton
data-state={currentTheme() != ThemeId.Darker ? 'on' : 'off'}
onClick={() => {
updateTheme(ThemeId.Light)
}}
>
Light
<Sun size={15} color={theme.colors.thTextContrast2.toString()} />
</StyledToggleButton>
<StyledToggleButton
data-state={currentTheme() == ThemeId.Darker ? 'on' : 'off'}
onClick={() => {
updateTheme(ThemeId.Darker)
}}
>
Dark
<Moon size={15} color={theme.colors.thTextContrast2.toString()} />
</StyledToggleButton>
</Box>
</HStack>
{props.layout && (
<HStack
alignment="center"
css={{
width: '100%',
px: '15px',
justifyContent: 'space-between',
}}
>
<StyledText
css={{
fontSize: '14px',
fontWeight: '400',
cursor: 'default',
color: '$utilityTextDefault',
}}
>
Layout
</StyledText>
<Box
css={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: '$thBackground4',
borderRadius: '5px',
height: '34px',
p: '3px',
px: '1px',
}}
>
<StyledToggleButton
data-state={props.layout == 'LIST_LAYOUT' ? 'on' : 'off'}
onClick={() => {
props.updateLayout && props.updateLayout('LIST_LAYOUT')
}}
>
<ListLayoutIcon
color={theme.colors.thTextContrast2.toString()}
/>
</StyledToggleButton>
<StyledToggleButton
data-state={props.layout == 'GRID_LAYOUT' ? 'on' : 'off'}
onClick={() => {
props.updateLayout && props.updateLayout('GRID_LAYOUT')
}}
>
<GridLayoutIcon
color={theme.colors.thTextContrast2.toString()}
/>
</StyledToggleButton>
</Box>
</HStack>
)}
</VStack>
<DropdownSeparator />
</>
)
}

View file

@ -1,20 +1,15 @@
import { PageMetaData, PageMetaDataProps } from '../patterns/PageMetaData'
import { Box } from '../elements/LayoutPrimitives'
import {
ReactNode,
MutableRefObject,
useEffect,
useState,
} from 'react'
import { PrimaryHeader } from './../patterns/PrimaryHeader'
import { ReactNode, useEffect, useState, useCallback } from 'react'
import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery'
import { navigationCommands } from '../../lib/keyboardShortcuts/navigationShortcuts'
import { useKeyboardShortcuts } from '../../lib/keyboardShortcuts/useKeyboardShortcuts'
import { useRouter } from 'next/router'
import { Analytics } from '@segment/analytics-next'
import { ConfirmationModal } from '../patterns/ConfirmationModal'
import { KeyboardShortcutListModal } from './KeyboardShortcutListModal'
import { logoutMutation } from '../../lib/networking/mutations/logoutMutation'
import { setupAnalytics } from '../../lib/analytics'
import { primaryCommands } from '../../lib/keyboardShortcuts/navigationShortcuts'
type PrimaryLayoutProps = {
children: ReactNode
@ -34,13 +29,25 @@ export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
useKeyboardShortcuts(navigationCommands(router))
useKeyboardShortcuts(
primaryCommands((action) => {
switch (action) {
case 'toggleShortcutHelpModalDisplay':
setShowKeyboardCommandsModal(true)
break
}
})
)
// Attempt to identify the user if they are logged in.
useEffect(() => {
setupAnalytics(viewerData?.me)
const user = window.analytics?.user().id()
if (!user && viewerData?.me?.id) {
window.analytics?.identify({ userId: viewerData?.me?.id })
}
}, [viewerData?.me?.id])
}, [viewerData?.me])
async function logout(): Promise<void> {
await logoutMutation()
@ -56,43 +63,37 @@ export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
}
}
const showLogout = useCallback(() => {
setShowLogoutConfirmation(true)
}, [setShowLogoutConfirmation])
useEffect(() => {
document.addEventListener('logout', showLogout)
return () => {
document.removeEventListener('logout', showLogout)
}
}, [showLogout])
return (
<>
{props.pageMetaDataProps ? (
<PageMetaData {...props.pageMetaDataProps} />
) : null}
<Box css={{
width: '100vw',
height: '100vh',
bg: 'transparent',
'@smDown': {
bg: '$grayBase',
}
}}>
<PrimaryHeader
user={viewerData?.me}
hideHeader={props.hideHeader}
userInitials={viewerData?.me?.name.charAt(0) ?? ''}
profileImageURL={viewerData?.me?.profile.pictureUrl}
isTransparent={true}
toolbarControl={props.headerToolbarControl}
alwaysDisplayToolbar={props.alwaysDisplayToolbar}
setShowLogoutConfirmation={setShowLogoutConfirmation}
setShowKeyboardCommandsModal={setShowKeyboardCommandsModal}
/>
<Box
css={{
width: '100vw',
height: '100vh',
bg: 'transparent',
}}
>
<Box
css={{
height: '100%',
width: '100vw',
bg: '$grayBase',
bg: '$thBackground2',
}}
>
<Box
css={{
height: '48px',
bg: '$grayBase',
}}
></Box>
{props.children}
{showLogoutConfirmation ? (
<ConfirmationModal

View file

@ -4,14 +4,7 @@ import {
ScrollOffsetChangeset,
useScrollWatcher,
} from '../../../lib/hooks/useScrollWatcher'
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'
import { Tweet } from 'react-twitter-widgets'
import { render } from 'react-dom'
import { isDarkTheme } from '../../../lib/themeUpdater'
@ -97,28 +90,6 @@ export function Article(props: ArticleProps): JSX.Element {
}
}, 1000)
const layoutImages = useCallback(
(image: HTMLImageElement, container: HTMLDivElement | null) => {
if (!container) return
const containerWidth = container.clientWidth + 140
if (!image.closest('blockquote, table')) {
let imageWidth = parseFloat(image.getAttribute('width') || '')
imageWidth = isNaN(imageWidth) ? image.naturalWidth : imageWidth
if (imageWidth > containerWidth) {
image.style.setProperty(
'width',
`${Math.min(imageWidth, containerWidth)}px`
)
image.style.setProperty('max-width', 'unset')
image.style.setProperty('margin-left', `-${Math.round(140 / 2)}px`)
}
}
},
[]
)
// Scroll to initial anchor position
useEffect(() => {
if (typeof window === 'undefined') {
@ -193,22 +164,6 @@ export function Article(props: ArticleProps): JSX.Element {
})
}, [])
const onLoadImageHandler = useCallback(() => {
const images = articleContentRef.current?.querySelectorAll('img')
images?.forEach((image) => {
layoutImages(image, articleContentRef.current)
})
}, [layoutImages])
useEffect(() => {
window.addEventListener('load', onLoadImageHandler)
return () => {
window.removeEventListener('load', onLoadImageHandler)
}
}, [onLoadImageHandler])
return (
<>
<link

View file

@ -1,21 +1,17 @@
import { Separator } from '@radix-ui/react-separator'
import {
ArchiveBox,
DotsThree,
HighlighterCircle,
Info,
TagSimple,
TextAa,
Trash,
Tray,
} from 'phosphor-react'
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { Button } from '../../elements/Button'
import { Dropdown } from '../../elements/DropdownElements'
import { Box, SpanBox } from '../../elements/LayoutPrimitives'
import { TooltipWrapped } from '../../elements/Tooltip'
import { styled, theme } from '../../tokens/stitches.config'
import { SetLabelsControl } from './SetLabelsControl'
import { DisplaySettingsModal } from './DisplaySettingsModal'
import { useReaderSettings } from '../../../lib/hooks/useReaderSettings'
import { useRef } from 'react'
import { setLabelsMutation } from '../../../lib/networking/mutations/setLabelsMutation'
@ -39,40 +35,12 @@ const MenuSeparator = (props: MenuSeparatorProps): JSX.Element => {
const LineSeparator = styled(Separator, {
width: '100%',
margin: 0,
borderBottom: `1px solid ${theme.colors.grayLine.toString()}`,
borderBottom: `1px solid ${theme.colors.thHighContrast.toString()}`,
my: '8px',
})
return props.layout == 'side' ? <LineSeparator /> : <></>
}
type ActionDropdownProps = {
layout: ArticleActionsMenuLayout
triggerElement: JSX.Element
children: JSX.Element
}
const ActionDropdown = (props: ActionDropdownProps): JSX.Element => {
return (
<Dropdown
css={{
m: '0px',
p: '0px',
overflow: 'hidden',
width: '265px',
maxWidth: '265px',
'@smDown': { width: '230px' },
}}
side={props.layout == 'side' ? 'right' : 'bottom'}
sideOffset={props.layout == 'side' ? 8 : 0}
align={props.layout == 'side' ? 'start' : 'center'}
alignOffset={props.layout == 'side' ? -18 : undefined}
triggerElement={props.triggerElement}
>
{props.children}
</Dropdown>
)
}
export function ArticleActionsMenu(
props: ArticleActionsMenuProps
): JSX.Element {
@ -87,32 +55,15 @@ export function ArticleActionsMenu(
alignItems: 'center',
flexDirection: props.layout == 'side' ? 'column' : 'row',
justifyContent: props.layout == 'side' ? 'center' : 'flex-end',
gap: props.layout == 'side' ? '8px' : '24px',
gap: props.layout == 'side' ? '15px' : '25px',
paddingTop: '6px',
}}
>
{props.showReaderDisplaySettings && (
<>
<Button
style="articleActionIcon"
onClick={() =>
readerSettings.setShowEditDisplaySettingsModal(true)
}
>
<TooltipWrapped
tooltipContent="Adjust Display Settings"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<TextAa size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>
</Button>
<MenuSeparator layout={props.layout} />
</>
)}
<SpanBox
css={{
display: 'flex',
'@smDown': {
alignItems: 'center',
'@mdDown': {
display: 'none',
},
}}
@ -130,7 +81,7 @@ export function ArticleActionsMenu(
<SpanBox ref={displaySettingsButtonRef}>
<TagSimple
size={24}
color={theme.colors.readerFont.toString()}
color={theme.colors.thHighContrast.toString()}
/>
</SpanBox>
</TooltipWrapped>
@ -138,36 +89,6 @@ export function ArticleActionsMenu(
<MenuSeparator layout={props.layout} />
</>
) : (
// <ActionDropdown
// layout={props.layout}
// triggerElement={
// <TooltipWrapped
// tooltipContent="Edit labels"
// tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
// >
// <TagSimple
// size={24}
// color={theme.colors.readerFont.toString()}
// />
// </TooltipWrapped>
// }
// >
// <SetLabelsControl
// provider={props.article}
// save={(labels: Label[]) => {
// if (props.article?.id) {
// return setLabelsMutation(
// props.article?.id,
// labels.map((label) => label.id)
// )
// }
// return Promise.resolve(undefined)
// }}
// onLabelsChanged={(labels) => {
// props.articleActionHandler('refreshLabels', labels)
// }}
// />
// </ActionDropdown>
<Button
style="articleActionIcon"
css={{
@ -176,7 +97,10 @@ export function ArticleActionsMenu(
},
}}
>
<TagSimple size={24} color={theme.colors.readerFont.toString()} />
<TagSimple
size={24}
color={theme.colors.thHighContrast.toString()}
/>
</Button>
)}
</SpanBox>
@ -186,17 +110,22 @@ export function ArticleActionsMenu(
onClick={() => props.articleActionHandler('setLabels')}
css={{
display: 'none',
'@smDown': {
'@mdDown': {
display: 'flex',
alignItems: 'center',
},
}}
>
<TagSimple size={24} color={theme.colors.readerFont.toString()} />
<TagSimple size={24} color={theme.colors.thHighContrast.toString()} />
</Button>
<Button
style="articleActionIcon"
onClick={() => props.articleActionHandler('showHighlights')}
css={{
display: 'flex',
alignItems: 'center',
}}
>
<TooltipWrapped
tooltipContent="View Highlights"
@ -204,11 +133,30 @@ export function ArticleActionsMenu(
>
<HighlighterCircle
size={24}
color={theme.colors.readerFont.toString()}
color={theme.colors.thHighContrast.toString()}
/>
</TooltipWrapped>
</Button>
<Button
style="articleActionIcon"
onClick={() => props.articleActionHandler('showEditModal')}
css={{
display: 'flex',
alignItems: 'center',
'@mdDown': {
display: 'none',
},
}}
>
<TooltipWrapped
tooltipContent="Edit title & description"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<Info size={24} color={theme.colors.thHighContrast.toString()} />
</TooltipWrapped>
</Button>
<MenuSeparator layout={props.layout} />
<Button
@ -216,12 +164,19 @@ export function ArticleActionsMenu(
onClick={() => {
props.articleActionHandler('delete')
}}
css={{
display: 'flex',
alignItems: 'center',
'@mdDown': {
display: 'none',
},
}}
>
<TooltipWrapped
tooltipContent="Delete"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<Trash size={24} color={theme.colors.readerFont.toString()} />
<Trash size={24} color={theme.colors.thHighContrast.toString()} />
</TooltipWrapped>
</Button>
@ -229,6 +184,10 @@ export function ArticleActionsMenu(
<Button
style="articleActionIcon"
onClick={() => props.articleActionHandler('archive')}
css={{
display: 'flex',
alignItems: 'center',
}}
>
<TooltipWrapped
tooltipContent="Archive"
@ -236,7 +195,7 @@ export function ArticleActionsMenu(
>
<ArchiveBox
size={24}
color={theme.colors.readerFont.toString()}
color={theme.colors.thHighContrast.toString()}
/>
</TooltipWrapped>
</Button>
@ -249,7 +208,7 @@ export function ArticleActionsMenu(
tooltipContent="Unarchive"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<Tray size={24} color={theme.colors.readerFont.toString()} />
<Tray size={24} color={theme.colors.thHighContrast.toString()} />
</TooltipWrapped>
</Button>
)}
@ -259,16 +218,6 @@ export function ArticleActionsMenu(
<DotsThree size={24} color={theme.colors.readerFont.toString()} />
</Button> */}
</Box>
{readerSettings.showEditDisplaySettingsModal && (
<DisplaySettingsModal
centerX={props.layout != 'side'}
triggerElementRef={displaySettingsButtonRef}
articleActionHandler={props.articleActionHandler}
onOpenChange={() =>
readerSettings.setShowEditDisplaySettingsModal(false)
}
/>
)}
{props.article && readerSettings.showSetLabelsModal && (
<SetLabelsModal

View file

@ -2,7 +2,10 @@ import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticle
import { Article } from './../../../components/templates/article/Article'
import { Box, HStack, SpanBox, VStack } from './../../elements/LayoutPrimitives'
import { StyledText } from './../../elements/StyledText'
import { ArticleSubtitle } from './../../patterns/ArticleSubtitle'
import {
ArticleSubtitle,
ReaderSavedInfo,
} from './../../patterns/ArticleSubtitle'
import { theme, ThemeId } from './../../tokens/stitches.config'
import { HighlightsLayer } from '../../templates/article/HighlightsLayer'
import { Button } from '../../elements/Button'
@ -16,6 +19,7 @@ import { LabelChip } from '../../elements/LabelChip'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { Recommendation } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { Avatar } from '../../elements/Avatar'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
type ArticleContainerProps = {
article: ArticleAttributes
@ -29,8 +33,9 @@ type ArticleContainerProps = {
fontFamily?: string
lineHeight?: number
maxWidthPercentage?: number
highContrastFont?: boolean
highContrastText?: boolean
showHighlightsModal: boolean
justifyText?: boolean
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
}
@ -68,7 +73,7 @@ const RecommendationComments = (
</StyledText>
</HStack>
{props.recommendationsWithNotes.map((item, idx) => (
{props.recommendationsWithNotes.map((item) => (
<VStack
key={item.id}
alignment="start"
@ -86,7 +91,6 @@ const RecommendationComments = (
<Avatar
imageURL={item.user?.profileImageURL}
height="28px"
noFade={true}
tooltip={item.user?.name}
fallbackText={item.user?.username[0] ?? 'U'}
/>
@ -114,8 +118,8 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
const [fontFamilyOverride, setFontFamilyOverride] = useState<string | null>(
null
)
const [highContrastFont, setHighContrastFont] = useState(
props.highContrastFont ?? false
const [highContrastText, setHighContrastText] = useState(
props.highContrastText ?? false
)
const highlightHref = useRef(
window.location.hash ? window.location.hash.split('#')[1] : null
@ -164,7 +168,6 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
const updateFontFamily = (event: UpdateFontFamilyEvent) => {
const newFontFamily =
event.fontFamily ?? fontFamilyOverride ?? props.fontFamily ?? 'inter'
console.log('setting font fam to', event.fontFamily)
setFontFamilyOverride(newFontFamily)
}
@ -174,7 +177,7 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
const handleFontContrastChange = async (event: UpdateFontContrastEvent) => {
const highContrast = event.fontContrast == 'high'
setHighContrastFont(highContrast)
setHighContrastText(highContrast)
}
interface UpdateFontSizeEvent extends Event {
@ -256,7 +259,7 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
maxWidthPercentage: maxWidthPercentageOverride ?? props.maxWidthPercentage,
lineHeight: lineHeightOverride ?? props.lineHeight ?? 150,
fontFamily: fontFamilyOverride ?? props.fontFamily ?? 'inter',
readerFontColor: highContrastFont
readerFontColor: highContrastText
? theme.colors.readerFontHighContrast.toString()
: theme.colors.readerFont.toString(),
readerTableHeaderColor: theme.colors.readerTableHeader.toString(),
@ -277,10 +280,14 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
id="article-container"
css={{
padding: '16px',
paddingTop: '80px',
maxWidth: `${styles.maxWidthPercentage ?? 100}%`,
background: props.isAppleAppEmbed
? 'unset'
: theme.colors.readerBg.toString(),
'.article-inner-css': {
textAlign: props.justifyText ? 'justify' : 'start',
},
'--text-font-family': styles.fontFamily,
'--text-font-size': `${styles.fontSize}px`,
'--line-height': `${styles.lineHeight}%`,
@ -296,7 +303,7 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
'--blockquote-icon-font-size': '1.7rem',
'--figure-margin': '2.6875rem auto',
'--hr-margin': '2em',
margin: `30px 0px`,
margin: `0px 0px`,
},
'@md': {
maxWidth: styles.maxWidthPercentage
@ -306,6 +313,11 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
}}
>
<VStack alignment="start" distribution="start">
<ReaderSavedInfo
rawDisplayDate={
props.article.publishedAt ?? props.article.createdAt
}
/>
<StyledText
style="boldHeadline"
data-testid="article-headline"
@ -318,9 +330,6 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
{props.article.title}
</StyledText>
<ArticleSubtitle
rawDisplayDate={
props.article.publishedAt ?? props.article.createdAt
}
author={props.article.author}
href={props.article.url}
/>
@ -397,18 +406,6 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
onOpenChange={(open: boolean) => setShowReportIssuesModal(open)}
/>
) : null}
{/* {showShareModal && (
<ShareArticleModal
url={`${webBaseURL}/${props.viewerUsername}/${props.article.slug}/highlights?r=true`}
title={props.article.title}
imageURL={props.article.image}
author={props.article.author}
publishedAt={props.article.publishedAt ?? props.article.createdAt}
description={props.article.description}
originalArticleUrl={props.article.originalArticleUrl}
onOpenChange={(open: boolean) => setShowShareModal(open)}
/>
)} */}
</>
)
}

View file

@ -1,93 +0,0 @@
import { useCallback } from 'react'
import { CommentIcon } from '../../elements/images/CommentIcon'
import { CopyLinkIcon } from '../../elements/images/CopyLinkIcon'
import { PostIcon } from '../../elements/images/PostIcon'
import { ShareIcon } from '../../elements/images/ShareIcon'
import { HStack } from './../../elements/LayoutPrimitives'
import { useCopyLink } from '../../../lib/hooks/useCopyLink'
import { Button } from '../../elements/Button'
import { theme } from './../../tokens/stitches.config'
import { StyledText } from './../../elements/StyledText'
import { useCanShareNative } from '../../../lib/hooks/useCanShareNative'
type ArticleHeaderToolbarProps = {
articleTitle: string
articleShareURL: string
hasHighlights: boolean
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
setShowShareArticleModal: (showShareModal: boolean) => void
}
export function ArticleHeaderToolbar(
props: ArticleHeaderToolbarProps
): JSX.Element {
const enablePostAction = false // disable for now
const { copyLink, isLinkCopied } = useCopyLink(props.articleShareURL, 'link')
const canShareNative = useCanShareNative()
const shareAction = useCallback(() => {
if (canShareNative) {
navigator
?.share({
title: props.articleTitle,
url: props.articleShareURL,
})
.then(() => {
return
})
.catch(() => {
return
})
} else {
props.setShowShareArticleModal(true)
}
}, [props, canShareNative])
return (
<HStack distribution="between" alignment="center" css={{ gap: '$2' }}>
{props.hasHighlights && (
<Button
style="plainIcon"
onClick={() => {
if (props.setShowHighlightsModal) {
props.setShowHighlightsModal(true)
}
}}
title="Notebook"
>
<CommentIcon
size={24}
strokeColor={theme.colors.grayTextContrast.toString()}
/>
</Button>
)}
{/* <Button style="plainIcon" onClick={copyLink} title="Copy Link">
<CopyLinkIcon
isCompleted={isLinkCopied}
strokeColor={theme.colors.grayTextContrast.toString()}
/>
</Button>
{enablePostAction ? (
<Button style="plainIcon" onClick={() => console.log('post action')} title="Post to Profile">
<PostIcon
isCompleted={false}
size={24}
strokeColor={theme.colors.grayTextContrast.toString()}
/>
</Button>
) : null}
<Button style="plainIcon" onClick={() => shareAction()} title="Share">
<ShareIcon
isCompleted={false}
size={24}
strokeColor={theme.colors.grayTextContrast.toString()}
/>
</Button>
{isLinkCopied ? (
<StyledText style="caption" css={{ m: 0, p: 0 }}>
Link Copied
</StyledText>
) : null} */}
</HStack>
)
}

View file

@ -1,31 +1,35 @@
import { ReaderSettings } from '../../../lib/hooks/useReaderSettings'
import { VStack } from '../../elements/LayoutPrimitives'
import {
ModalRoot,
ModalOverlay,
ModalContent,
ModalOverlay,
} from '../../elements/ModalPrimitives'
import { ReaderSettingsControl } from './ReaderSettingsControl'
type DisplaySettingsModalProps = {
centerX: boolean
onOpenChange: (open: boolean) => void
triggerElementRef?: React.RefObject<HTMLElement>
articleActionHandler: (action: string, arg?: number | string) => void
readerSettings: ReaderSettings
}
export function DisplaySettingsModal(props: DisplaySettingsModalProps): JSX.Element {
const top = props.triggerElementRef?.current?.getBoundingClientRect().bottom ?? 0
const left = props.triggerElementRef?.current?.getBoundingClientRect().left ?? 0
export function DisplaySettingsModal(
props: DisplaySettingsModalProps
): JSX.Element {
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
<ModalOverlay />
<ModalContent
css={{
width: '245px',
top: props.triggerElementRef?.current ? top : '50%',
left: props.triggerElementRef?.current ? (left - (props.centerX ? 265 / 2 : 0)) : '50%',
transform: props.triggerElementRef?.current ? 'unset' : 'translate(-50%, -50%)',
width: '345px',
padding: '0px',
top: '300px',
left: 'calc(100% - 250px)',
'@lgDown': {
top: '300px',
left: '50%',
},
}}
onPointerDownOutside={(event) => {
event.preventDefault()
@ -33,9 +37,7 @@ export function DisplaySettingsModal(props: DisplaySettingsModalProps): JSX.Elem
}}
>
<VStack css={{ width: '100%' }}>
<ReaderSettingsControl
articleActionHandler={props.articleActionHandler}
/>
<ReaderSettingsControl readerSettings={props.readerSettings} />
</VStack>
</ModalContent>
</ModalRoot>

View file

@ -1,7 +1,7 @@
import { HStack, Box } from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { theme } from '../../tokens/stitches.config'
import { CaretLeft, Check, CheckCircle } from 'phosphor-react'
import { CaretLeft, Check } from 'phosphor-react'
const FONT_FAMILIES = [
'Inter',
@ -12,7 +12,7 @@ const FONT_FAMILIES = [
'Roboto',
'Crimson Text',
'OpenDyslexic',
'Source Serif Pro'
'Source Serif Pro',
]
type FontFamiliesListProps = {
@ -27,18 +27,28 @@ type FontOptionProps = {
onSelect: (value: string) => void
}
function FontOption(props: FontOptionProps):JSX.Element {
function FontOption(props: FontOptionProps): JSX.Element {
const isSelected = props.selected === props.family
return (
<HStack distribution='between' alignment='start' css={{ width: '100%', pt: '14px' }}>
<HStack
distribution="between"
alignment="start"
css={{ width: '100%', pt: '14px' }}
>
<StyledText
css={{ m: '0px', fontSize: 16, fontFamily: props.family, textTransform: 'capitalize', cursor: 'pointer' }}
css={{
m: '0px',
fontSize: 16,
fontFamily: props.family,
textTransform: 'capitalize',
cursor: 'pointer',
}}
onClick={() => props.onSelect(props.family)}
>
{props.family}
</StyledText>
{isSelected && (
<Check color={theme.colors.grayTextContrast.toString()} weight='bold' />
<Check color={theme.colors.grayTextContrast.toString()} weight="bold" />
)}
</HStack>
)
@ -47,22 +57,50 @@ function FontOption(props: FontOptionProps):JSX.Element {
export function FontFamiliesOptions(props: FontFamiliesListProps): JSX.Element {
return (
<>
<Box css={{borderBottom: `1px solid ${theme.colors.grayLine.toString()}`, width: '100%'}}>
<HStack alignment='center' distribution='between' css={{width: '100%', py: 10, px: 15}}>
<Box
css={{
borderBottom: `1px solid ${theme.colors.grayLine.toString()}`,
width: '100%',
}}
>
<HStack
alignment="center"
distribution="between"
css={{ width: '100%', py: 10, px: 15 }}
>
<HStack
alignment='center'
distribution='start'
alignment="center"
distribution="start"
css={{ cursor: 'pointer', py: '4px', width: '100%' }}
onClick={() => props.setShowFontFamilies(false)}
>
<CaretLeft color={theme.colors.utilityTextSubtle.toString()} size={15} />
<StyledText css={{ textAlign: 'center', m: 0, fontSize: 14, fontWeight: 'bold', width: '100%', wordWrap: 'revert' }}>Choose Font</StyledText>
<CaretLeft
color={theme.colors.utilityTextSubtle.toString()}
size={15}
/>
<StyledText
css={{
textAlign: 'center',
m: 0,
fontSize: 14,
fontWeight: 'bold',
width: '100%',
wordWrap: 'revert',
}}
>
Choose Font
</StyledText>
</HStack>
</HStack>
</Box>
<Box css={{ px: 15, pb: 15, width: '100%' }}>
{FONT_FAMILIES.map((family) => (
<FontOption selected={props.selected} family={family} onSelect={props.onSelect} key={`font-${family}`} />
<FontOption
selected={props.selected}
family={family}
onSelect={props.onSelect}
key={`font-${family}`}
/>
))}
</Box>
</>

View file

@ -0,0 +1,37 @@
import { Box } from '../../elements/LayoutPrimitives'
import { theme } from '../../tokens/stitches.config'
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { HighlightView } from '../../patterns/HighlightView'
type PageCoordinates = {
pageX: number
pageY: number
}
type HighlightHoverCardProps = {
highlight: Highlight
anchorCoordinates: PageCoordinates
}
export function HighlightHoverCard(
props: HighlightHoverCardProps
): JSX.Element {
return (
<Box
css={{
width: '100%',
maxWidth: '330px',
// height: '48px',
position: 'absolute',
background: '$grayBg',
borderRadius: '4px',
border: '1px solid $grayBorder',
boxShadow: theme.shadows.cardBoxShadow.toString(),
left: props.anchorCoordinates.pageX,
top: props.anchorCoordinates.pageY,
}}
>
<HighlightView highlight={props.highlight} />
</Box>
)
}

View file

@ -5,19 +5,12 @@ import {
ModalTitleBar,
ModalButtonBar,
} from './../../elements/ModalPrimitives'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { CommentIcon } from '../../elements/images/CommentIcon'
import { theme } from '../../tokens/stitches.config'
import { VStack } from '../../elements/LayoutPrimitives'
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { useCallback, useState } from 'react'
import { StyledTextArea } from '../../elements/StyledTextArea'
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
import { readableUpdatedAtMessage } from './../../../lib/dateFormatting'
import { useConfirmListener } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
import { showErrorToast } from '../../../lib/toastHelpers'
import { CrossIcon } from '../../elements/images/CrossIcon'
type HighlightNoteModalProps = {
author: string
@ -35,18 +28,6 @@ export function HighlightNoteModal(
props.highlight?.annotation ?? ''
)
useConfirmListener(
() => {
saveNoteChanges()
},
undefined,
true
)
const updatedAtMessage = props.highlight
? readableUpdatedAtMessage(props.highlight?.updatedAt)
: undefined
const handleNoteContentChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>): void => {
setNoteContent(event.target.value)

View file

@ -18,7 +18,6 @@ import { HighlightBar, HighlightAction } from '../../patterns/HighlightBar'
import { removeHighlights } from '../../../lib/highlights/deleteHighlight'
import { createHighlight } from '../../../lib/highlights/createHighlight'
import { HighlightNoteModal } from './HighlightNoteModal'
import { ShareHighlightModal } from './ShareHighlightModal'
import { NotebookModal } from './NotebookModal'
import { useCanShareNative } from '../../../lib/hooks/useCanShareNative'
import { showErrorToast } from '../../../lib/toastHelpers'
@ -69,18 +68,54 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
>([])
const focusedHighlightMousePos = useRef({ pageX: 0, pageY: 0 })
const [focusedHighlight, setFocusedHighlight] = useState<
Highlight | undefined
>(undefined)
const [focusedHighlight, setFocusedHighlight] =
useState<Highlight | undefined>(undefined)
const [selectionData, setSelectionData] = useSelection(highlightLocations)
const [labelsTarget, setLabelsTarget] = useState<Highlight | undefined>(
undefined
)
const [labelsTarget, setLabelsTarget] =
useState<Highlight | undefined>(undefined)
const canShareNative = useCanShareNative()
const createHighlightFromSelection = async (
selection: SelectionAttributes,
note?: string
): Promise<Highlight | undefined> => {
const result = await createHighlight(
{
selection: selection,
articleId: props.articleId,
existingHighlights: highlights,
highlightStartEndOffsets: highlightLocations,
annotation: note,
highlightPositionPercent: selectionPercentPos(selection.selection),
highlightPositionAnchorIndex: selectionAnchorIndex(selection.selection),
},
props.articleMutations
)
if (result.errorMessage) {
throw 'Failed to create highlight: ' + result.errorMessage
}
if (!result.highlights || result.highlights.length == 0) {
// TODO: show an error message
console.error('Failed to create highlight')
return undefined
}
setSelectionData(null)
setHighlights(result.highlights)
if (result.newHighlightIndex === undefined) {
setHighlightModalAction({ highlightModalAction: 'none' })
return undefined
}
return result.highlights[result.newHighlightIndex]
}
// Load the highlights
useEffect(() => {
const res: HighlightLocation[] = []
@ -105,7 +140,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
anchorElement.scrollIntoView({ behavior: 'auto' })
}
}
}, [highlights, setHighlightLocations])
}, [highlights, setHighlightLocations, props.scrollToHighlight])
const removeHighlightCallback = useCallback(
async (id?: string) => {
@ -130,7 +165,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
console.error('Failed to delete highlight')
}
},
[focusedHighlight, highlights, highlightLocations]
[focusedHighlight, highlights, highlightLocations, props.articleMutations]
)
const updateHighlightsCallback = useCallback(
@ -224,44 +259,6 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
return undefined
}
const createHighlightFromSelection = async (
selection: SelectionAttributes,
note?: string
): Promise<Highlight | undefined> => {
const result = await createHighlight(
{
selection: selection,
articleId: props.articleId,
existingHighlights: highlights,
highlightStartEndOffsets: highlightLocations,
annotation: note,
highlightPositionPercent: selectionPercentPos(selection.selection),
highlightPositionAnchorIndex: selectionAnchorIndex(selection.selection),
},
props.articleMutations
)
if (result.errorMessage) {
throw 'Failed to create highlight: ' + result.errorMessage
}
if (!result.highlights || result.highlights.length == 0) {
// TODO: show an error message
console.error('Failed to create highlight')
return undefined
}
setSelectionData(null)
setHighlights(result.highlights)
if (result.newHighlightIndex === undefined) {
setHighlightModalAction({ highlightModalAction: 'none' })
return undefined
}
return result.highlights[result.newHighlightIndex]
}
const createHighlightCallback = useCallback(
async (successAction: HighlightModalAction, annotation?: string) => {
if (!selectionData) {
@ -354,7 +351,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
setFocusedHighlight(undefined)
}
},
[highlights, highlightLocations]
[highlights, highlightLocations, openNoteModal]
)
useEffect(() => {
@ -441,6 +438,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
props.isAppleAppEmbed,
removeHighlightCallback,
canShareNative,
selectionData,
]
)
@ -601,7 +599,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
return (
<SetLabelsModal
provider={labelsTarget}
onOpenChange={function (open: boolean): void {
onOpenChange={function (): void {
setLabelsTarget(undefined)
}}
save={function (labels: Label[]): Promise<Label[] | undefined> {
@ -615,23 +613,6 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
)
}
if (
highlightModalAction?.highlightModalAction == 'share' &&
highlightModalAction.highlight
) {
return (
<ShareHighlightModal
url={`${props.highlightsBaseURL}/${highlightModalAction.highlight.shortId}`}
title={props.articleTitle}
author={props.articleAuthor}
highlight={highlightModalAction.highlight}
onOpenChange={() => {
setHighlightModalAction({ highlightModalAction: 'none' })
}}
/>
)
}
// Display the button bar if we are not in the native app and there
// is a focused highlight or selection data
if (!props.highlightBarDisabled && (focusedHighlight || selectionData)) {

View file

@ -4,20 +4,14 @@ import {
ModalContent,
ModalTitleBar,
} from '../../elements/ModalPrimitives'
import {
Box,
HStack,
VStack,
Separator,
SpanBox,
} from '../../elements/LayoutPrimitives'
import { Box, HStack, VStack, SpanBox } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { TrashIcon } from '../../elements/images/TrashIcon'
import { theme } from '../../tokens/stitches.config'
import type { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { HighlightView } from '../../patterns/HighlightView'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { StyledTextArea } from '../../elements/StyledTextArea'
import { ConfirmationModal } from '../../patterns/ConfirmationModal'
import { DotsThree } from 'phosphor-react'
@ -28,6 +22,7 @@ import { setLabelsForHighlight } from '../../../lib/networking/mutations/setLabe
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { diff_match_patch } from 'diff-match-patch'
import { HighlightNoteTextEditArea } from '../../elements/HighlightNoteTextEditArea'
type NotebookModalProps = {
highlights: Highlight[]
@ -215,12 +210,12 @@ function ModalHighlightView(props: ModalHighlightViewProps): JSX.Element {
{!isEditing ? (
<StyledText
css={{
borderRadius: '6px',
bg: '$grayBase',
borderRadius: '5px',
p: '16px',
width: '100%',
marginTop: '24px',
color: '$grayText',
bg: '#EBEBEB',
color: '#3D3D3D',
}}
onClick={() => setIsEditing(true)}
>
@ -230,7 +225,7 @@ function ModalHighlightView(props: ModalHighlightViewProps): JSX.Element {
</StyledText>
) : null}
{isEditing && (
<TextEditArea
<HighlightNoteTextEditArea
setIsEditing={setIsEditing}
highlight={props.highlight}
updateHighlight={props.updateHighlight}
@ -241,86 +236,3 @@ function ModalHighlightView(props: ModalHighlightViewProps): JSX.Element {
</>
)
}
type TextEditAreaProps = {
setIsEditing: (editing: boolean) => void
highlight: Highlight
updateHighlight: (highlight: Highlight) => void
}
const TextEditArea = (props: TextEditAreaProps): JSX.Element => {
const [noteContent, setNoteContent] = useState(
props.highlight.annotation ?? ''
)
const handleNoteContentChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>): void => {
setNoteContent(event.target.value)
},
[setNoteContent]
)
return (
<VStack css={{ width: '100%' }} key="textEditor">
<StyledTextArea
css={{
my: '$3',
minHeight: '$6',
borderRadius: '6px',
bg: '$grayBase',
p: '16px',
width: '100%',
marginTop: '16px',
resize: 'vertical',
}}
autoFocus
maxLength={4000}
value={noteContent}
placeholder={'Add your notes...'}
onChange={handleNoteContentChange}
/>
<HStack alignment="center" distribution="end" css={{ width: '100%' }}>
<Button
style="ctaPill"
css={{ mr: '$2' }}
onClick={() => {
props.setIsEditing(false)
setNoteContent(props.highlight.annotation ?? '')
}}
>
Cancel
</Button>
<Button
style="ctaDarkYellow"
onClick={async (e) => {
e.preventDefault()
console.log('updating highlight')
try {
const result = await updateHighlightMutation({
highlightId: props.highlight.id,
annotation: noteContent,
})
console.log('result: ' + result)
if (!result) {
showErrorToast('There was an error updating your highlight.')
} else {
showSuccessToast('Note saved')
props.highlight.annotation = noteContent
props.updateHighlight(props.highlight)
}
} catch (err) {
console.log('error updating annoation', err)
showErrorToast('There was an error updating your highlight.')
}
props.setIsEditing(false)
}}
>
Save
</Button>
</HStack>
</VStack>
)
}

View file

@ -2,13 +2,7 @@ import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticle
import { Box } from '../../elements/LayoutPrimitives'
import { v4 as uuidv4 } from 'uuid'
import { nanoid } from 'nanoid'
import {
useState,
useEffect,
useCallback,
useRef,
ReactComponentElement,
} from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { isDarkTheme } from '../../../lib/themeUpdater'
import PSPDFKit from 'pspdfkit'
import { Instance, HighlightAnnotation, List, Annotation, Rect } from 'pspdfkit'
@ -17,7 +11,6 @@ import { createHighlightMutation } from '../../../lib/networking/mutations/creat
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation'
import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeHighlightMutation'
import { ShareHighlightModal } from './ShareHighlightModal'
import { useCanShareNative } from '../../../lib/hooks/useCanShareNative'
import { webBaseURL } from '../../../lib/appConfig'
import { pspdfKitKey } from '../../../lib/appConfig'
@ -36,14 +29,12 @@ export default function PdfArticleContainer(
props: PdfArticleContainerProps
): JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null)
const [shareTarget, setShareTarget] = useState<Highlight | undefined>(
undefined
)
const [shareTarget, setShareTarget] =
useState<Highlight | undefined>(undefined)
const [notebookKey, setNotebookKey] = useState<string>(uuidv4())
const [noteTarget, setNoteTarget] = useState<Highlight | undefined>(undefined)
const [noteTargetPageIndex, setNoteTargetPageIndex] = useState<
number | undefined
>(undefined)
const [noteTargetPageIndex, setNoteTargetPageIndex] =
useState<number | undefined>(undefined)
const highlightsRef = useRef<Highlight[]>([])
const canShareNative = useCanShareNative()
@ -465,17 +456,6 @@ export default function PdfArticleContainer(
return (
<Box css={{ width: '100%', height: '100%' }}>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
{shareTarget && (
<ShareHighlightModal
url={getHighlightURL(shareTarget.shortId)}
title={props.article.title}
author={props.article.author}
highlight={shareTarget}
onOpenChange={() => {
setShareTarget(undefined)
}}
/>
)}
{noteTarget && (
<HighlightNoteModal
highlight={noteTarget}

View file

@ -1,284 +1,605 @@
import { HStack, VStack, SpanBox, Box } from '../../elements/LayoutPrimitives'
import { HStack, VStack, SpanBox } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { styled, theme } from '../../tokens/stitches.config'
import { useEffect, useState } from 'react'
import { styled, theme, ThemeId } from '../../tokens/stitches.config'
import {
AlignCenterHorizontalSimple,
ArrowsHorizontal,
ArrowsInLineHorizontal,
ArrowsOutLineHorizontal,
CaretLeft,
CaretRight,
Cursor,
Check,
} from 'phosphor-react'
import { TickedRangeSlider } from '../../elements/TickedRangeSlider'
import { showSuccessToast } from '../../../lib/toastHelpers'
import { FontFamiliesOptions } from './FontFamiliesOptions'
import { useReaderSettings } from '../../../lib/hooks/useReaderSettings'
import { ReaderSettings } from '../../../lib/hooks/useReaderSettings'
import { useCallback, useMemo, useState } from 'react'
import { currentThemeName, updateTheme } from '../../../lib/themeUpdater'
import { LineHeightIncreaseIcon } from '../../elements/images/LineHeightIncreaseIconProps'
import { LineHeightDecreaseIcon } from '../../elements/images/LineHeightDecreaseIcon'
import * as Switch from '@radix-ui/react-switch'
type ReaderSettingsProps = {
articleActionHandler: (action: string, arg?: number | string) => void
readerSettings: ReaderSettings
}
const VerticalDivider = styled(SpanBox, {
width: '1px',
height: '100%',
background: `${theme.colors.grayLine.toString()}`,
})
const HorizontalDivider = styled(SpanBox, {
width: '100%',
height: '1px',
background: `${theme.colors.grayLine.toString()}`,
})
const FONT_FAMILIES = [
'Inter',
'System Default',
'Merriweather',
'Lora',
'Open Sans',
'Roboto',
'Crimson Text',
'OpenDyslexic',
'Source Serif Pro',
]
type SettingsProps = {
readerSettings: ReaderSettings
setShowAdvanced: (show: boolean) => void
}
export function ReaderSettingsControl(props: ReaderSettingsProps): JSX.Element {
const [showFontOptions, setShowFontOptions] = useState(false)
const readerSettings = useReaderSettings()
const [showAdvanced, setShowAdvanced] = useState(false)
return (
<VStack css={{ width: '100%' }}>
{showFontOptions ? (
<FontFamiliesOptions
selected={readerSettings.fontFamily}
setShowFontFamilies={setShowFontOptions}
onSelect={(font: string) => {
readerSettings.setFontFamily(font)
props.articleActionHandler('setFontFamily', font)
}}
<>
{showAdvanced ? (
<AdvancedSettings
readerSettings={props.readerSettings}
setShowAdvanced={setShowAdvanced}
/>
) : (
<>
<HStack
alignment="center"
distribution="between"
css={{
width: '100%',
height: '44px',
verticalAlign: 'baseline',
borderBottom: `1px solid ${theme.colors.grayLine.toString()}`,
}}
>
<Button
style="plainIcon"
css={{ width: '50%', fontSize: '32px' }}
onClick={() => props.articleActionHandler('decrementFontSize')}
>
-
</Button>
<VerticalDivider />
<Button
style="plainIcon"
css={{ width: '50%', height: '100%', fontSize: '32px' }}
onClick={() => props.articleActionHandler('incrementFontSize')}
>
+
</Button>
</HStack>
<HStack
distribution="start"
alignment="center"
css={{
m: '0px',
px: '12px',
py: '12px',
width: '100%',
height: '44px',
verticalAlign: 'baseline',
cursor: 'pointer',
}}
onClick={() => setShowFontOptions(true)}
>
<StyledText css={{ m: '0px' }}>Font:</StyledText>
<StyledText
css={{
m: '0px',
marginLeft: 'auto',
pr: '4px',
fontFamily: readerSettings.fontFamily,
textTransform: 'capitalize',
}}
>
{readerSettings.fontFamily}
</StyledText>
<CaretRight
width={16}
height={16}
color={theme.colors.grayTextContrast.toString()}
/>
</HStack>
<HorizontalDivider
css={{
'@smDown': {
display: 'none',
},
}}
/>
<VStack
css={{
p: '0px',
m: '0px',
pb: '14px',
width: '100%',
height: '100%',
'@smDown': {
display: 'none',
},
}}
>
<StyledText
color={theme.colors.readerFont.toString()}
css={{ pl: '12px', m: '0px', pt: '14px' }}
>
Margin:
</StyledText>
<HStack
distribution="between"
css={{ gap: '16px', alignItems: 'center', alignSelf: 'center' }}
>
<Button
style="plainIcon"
css={{ pt: '10px', pl: '12px' }}
onClick={() => {
const newMarginWith = Math.max(
readerSettings.marginWidth - 45,
200
)
readerSettings.setMarginWidth(newMarginWith)
props.articleActionHandler('setMarginWidth', newMarginWith)
}}
>
<ArrowsOutLineHorizontal
size={24}
color={theme.colors.readerFont.toString()}
/>
</Button>
<TickedRangeSlider
min={200}
max={560}
step={45}
value={readerSettings.marginWidth}
onChange={(value) => {
readerSettings.setMarginWidth(value)
props.articleActionHandler('setMarginWidth', value)
}}
/>
<Button
style="plainIcon"
css={{ pt: '10px', pr: '12px' }}
onClick={() => {
const newMarginWith = Math.min(
readerSettings.marginWidth + 45,
560
)
readerSettings.setMarginWidth(newMarginWith)
props.articleActionHandler('setMarginWidth', newMarginWith)
}}
>
<ArrowsInLineHorizontal
size={24}
color={theme.colors.readerFont.toString()}
/>
</Button>
</HStack>
</VStack>
<HorizontalDivider />
<VStack
css={{
p: '0px',
m: '0px',
pb: '14px',
width: '100%',
height: '100%',
}}
>
<StyledText
color={theme.colors.readerFont.toString()}
css={{ pl: '12px', m: '0px', pt: '14px' }}
>
Line Spacing:
</StyledText>
<HStack
distribution="between"
css={{ gap: '16px', alignItems: 'center', alignSelf: 'center' }}
>
<Button
style="plainIcon"
css={{ pt: '10px', pl: '12px' }}
onClick={() => {
const newLineHeight = Math.max(
readerSettings.lineHeight - 25,
100
)
readerSettings.setLineHeight(newLineHeight)
props.articleActionHandler('setLineHeight', newLineHeight)
}}
>
<AlignCenterHorizontalSimple
size={25}
color={theme.colors.readerFont.toString()}
/>
</Button>
<TickedRangeSlider
min={100}
max={300}
step={25}
value={readerSettings.lineHeight}
onChange={(value) => {
readerSettings.setLineHeight(value)
props.articleActionHandler('setLineHeight', value)
}}
/>
<Button
style="plainIcon"
css={{ pt: '10px', pr: '12px' }}
onClick={() => {
const newLineHeight = Math.min(
readerSettings.lineHeight + 25,
300
)
readerSettings.setLineHeight(newLineHeight)
props.articleActionHandler('setLineHeight', newLineHeight)
}}
>
<AlignCenterHorizontalSimple
size={25}
color={theme.colors.readerFont.toString()}
/>
</Button>
</HStack>
</VStack>
<HorizontalDivider />
<Button
style="plainIcon"
css={{
justifyContent: 'center',
textDecoration: 'underline',
display: 'flex',
gap: '4px',
width: '100%',
fontSize: '12px',
p: '12px',
pb: '14px',
pt: '16px',
height: '42px',
alignItems: 'center',
}}
onClick={() => {
readerSettings.setFontFamily('Inter')
readerSettings.setMarginWidth(290)
readerSettings.setLineHeight(150)
props.articleActionHandler('resetReaderSettings')
showSuccessToast('Display settings reset', {
position: 'bottom-right',
})
}}
>
Reset to default
</Button>
</>
<BasicSettings
readerSettings={props.readerSettings}
setShowAdvanced={setShowAdvanced}
/>
)}
</>
)
}
function AdvancedSettings(props: SettingsProps): JSX.Element {
return (
<VStack
css={{ width: '100%', minHeight: '320px', p: '10px' }}
alignment="start"
distribution="start"
>
<Button
style="plainIcon"
css={{
m: '10px',
mb: '20px',
display: 'flex',
fontSize: '12px',
fontWeight: '400',
fontFamily: '$display',
alignItems: 'center',
borderRadius: '6px',
gap: '5px',
'&:hover': {
textDecoration: 'underline',
},
}}
onClick={() => {
props.setShowAdvanced(false)
}}
>
<CaretLeft size={12} color="#969696" weight="bold" />
Back
</Button>
<HStack
css={{
width: '100%',
pr: '30px',
alignItems: 'center',
'&:hover': {
opacity: 0.8,
},
'&[data-state="on"]': {
bg: '$thBackground',
},
}}
alignment="start"
distribution="between"
>
<Label htmlFor="justify-text" css={{ width: '100%' }}>
<StyledText style="displaySettingsLabel" css={{ pl: '20px' }}>
Justify Text
</StyledText>
</Label>
<SwitchRoot
id="justify-text"
checked={props.readerSettings.justifyText ?? false}
onCheckedChange={(checked) => {
props.readerSettings.setJustifyText(checked)
}}
>
<SwitchThumb />
</SwitchRoot>
</HStack>
<HStack
css={{
width: '100%',
pr: '30px',
alignItems: 'center',
'&:hover': {
opacity: 0.8,
},
'&[data-state="on"]': {
bg: '$thBackground',
},
}}
alignment="start"
distribution="between"
>
<Label htmlFor="high-contrast-text" css={{ width: '100%' }}>
<StyledText style="displaySettingsLabel" css={{ pl: '20px' }}>
High Contrast Text
</StyledText>
</Label>
<SwitchRoot
id="high-contrast-text"
checked={props.readerSettings.highContrastText ?? false}
onCheckedChange={(checked) => {
props.readerSettings.setHighContrastText(checked)
}}
>
<SwitchThumb />
</SwitchRoot>
</HStack>
</VStack>
)
}
const SwitchRoot = styled(Switch.Root, {
all: 'unset',
width: 42,
height: 25,
backgroundColor: '$thBorderColor',
borderRadius: '9999px',
position: 'relative',
WebkitTapHighlightColor: 'rgba(0, 0, 0, 0)',
'&:focus': { boxShadow: `0 0 0 2px $thBorderColor` },
'&[data-state="checked"]': { backgroundColor: '$thBorderColor' },
})
const SwitchThumb = styled(Switch.Thumb, {
display: 'block',
width: 21,
height: 21,
backgroundColor: '$thTextContrast2',
borderRadius: '9999px',
transition: 'transform 100ms',
transform: 'translateX(2px)',
willChange: 'transform',
'&[data-state="checked"]': { transform: 'translateX(19px)' },
})
const Label = styled('label', {
color: 'white',
fontSize: 15,
lineHeight: 1,
})
function BasicSettings(props: SettingsProps): JSX.Element {
return (
<VStack css={{ width: '100%' }}>
<FontControls readerSettings={props.readerSettings} />
<HorizontalDivider />
<LayoutControls readerSettings={props.readerSettings} />
<HorizontalDivider />
<ThemeSelector {...props} />
<HorizontalDivider />
<HStack
distribution="start"
alignment="center"
css={{ width: '100%', p: '10px' }}
>
<Button
style="plainIcon"
css={{
pl: '10px',
display: 'flex',
fontSize: '12px',
fontWeight: '600',
fontFamily: '$display',
'&:hover': {
textDecoration: 'underline',
},
}}
onClick={() => {
props.readerSettings.setFontFamily('Inter')
props.readerSettings.setMarginWidth(290)
props.readerSettings.setLineHeight(150)
props.readerSettings.actionHandler('resetReaderSettings')
showSuccessToast('Display settings reset', {
position: 'bottom-right',
})
}}
>
Reset
</Button>
<Button
style="plainIcon"
css={{
p: '5px',
display: 'flex',
fontSize: '12px',
fontWeight: '400',
fontFamily: '$display',
marginLeft: 'auto',
alignItems: 'center',
borderRadius: '6px',
gap: '5px',
'&:hover': {
textDecoration: 'underline',
},
}}
onClick={() => {
props.setShowAdvanced(true)
}}
>
Advanced Settings
<CaretRight size={12} color="#969696" weight="bold" />
</Button>
</HStack>
</VStack>
)
}
type FontControlsProps = {
readerSettings: ReaderSettings
}
function FontControls(props: FontControlsProps): JSX.Element {
const FontSelect = styled('select', {
pl: '5px',
height: '30px',
minWidth: '100px',
display: 'flex',
alignItems: 'center',
fontSize: '12px',
background: '$thBackground',
border: '1px solid $thBorderColor',
fontFamily: props.readerSettings.fontFamily,
textTransform: 'capitalize',
borderRadius: '4px',
})
const handleFontSizeChange = useCallback(
(value) => {
props.readerSettings.actionHandler('setFontSize', value)
},
[props.readerSettings.actionHandler]
)
return (
<VStack css={{ width: '100%', pb: '20px' }}>
<HStack
distribution="start"
alignment="start"
css={{ width: '100%', p: '20px', pb: '10px' }}
>
<StyledText style="displaySettingsLabel">Font</StyledText>
<FontSelect
css={{ marginLeft: 'auto' }}
tabIndex={-1}
defaultValue={props.readerSettings.fontFamily}
onChange={(e: React.FormEvent<HTMLSelectElement>) => {
const font = e.currentTarget.value
if (FONT_FAMILIES.indexOf(font) < 0) {
return
}
props.readerSettings.setFontFamily(font)
}}
>
{FONT_FAMILIES.map((family) => (
<option key={`font-${family}`} value={family}>
{family}
</option>
))}
</FontSelect>
</HStack>
<HStack
css={{ px: '10px', width: '100%' }}
distribution="start"
alignment="center"
>
<Button
style="plainIcon"
css={{ py: '0px', width: '60px' }}
onClick={() => {
props.readerSettings.actionHandler('decrementFontSize')
}}
>
<SpanBox
css={{
fontFamily: '$display',
fontStyle: 'normal',
fontWeight: '400',
fontSize: '20px',
lineHeight: '20px',
textAlign: 'center',
color: '#969696',
width: '50px',
pr: '5px',
pb: '5px',
}}
>
a
</SpanBox>
</Button>
<TickedRangeSlider
min={10}
max={34}
step={2}
value={props.readerSettings.fontSize}
onChange={handleFontSizeChange}
/>
<Button
style="plainIcon"
css={{ py: '0px', width: '60px' }}
onClick={() => {
props.readerSettings.actionHandler('incrementFontSize')
}}
>
<SpanBox
css={{
fontFamily: '$display',
fontStyle: 'normal',
fontWeight: '400',
fontSize: '20px',
lineHeight: '20px',
textAlign: 'center',
color: '#969696',
width: '60px',
pl: '16px',
}}
>
A
</SpanBox>
</Button>
</HStack>
</VStack>
)
}
type LayoutControlsProps = {
readerSettings: ReaderSettings
}
function LayoutControls(props: LayoutControlsProps): JSX.Element {
const handleMarginWidthChange = useCallback(
(value) => {
props.readerSettings.setMarginWidth(value)
},
[props.readerSettings.actionHandler, props.readerSettings.setMarginWidth]
)
return (
<>
<VStack
css={{
m: '0px',
px: '0px',
pb: '10px',
width: '100%',
height: '100%',
}}
>
<StyledText style="displaySettingsLabel" css={{ pl: '20px' }}>
Margin
</StyledText>
<HStack
distribution="between"
css={{
width: '100%',
alignItems: 'center',
alignSelf: 'center',
}}
>
<Button
style="plainIcon"
css={{ py: '0px', width: '60px' }}
onClick={() => {
const newMarginWith = Math.max(
props.readerSettings.marginWidth - 45,
200
)
props.readerSettings.setMarginWidth(newMarginWith)
}}
>
<ArrowsHorizontal size={24} color="#969696" />
</Button>
<TickedRangeSlider
min={200}
max={560}
step={45}
value={props.readerSettings.marginWidth}
onChange={handleMarginWidthChange}
/>
<Button
style="plainIcon"
css={{ py: '0px', width: '60px' }}
onClick={() => {
const newMarginWith = Math.min(
props.readerSettings.marginWidth + 45,
560
)
props.readerSettings.setMarginWidth(newMarginWith)
}}
>
<ArrowsInLineHorizontal size={24} color="#969696" />
</Button>
</HStack>
</VStack>
<VStack
css={{
m: '0px',
px: '0px',
pb: '20px',
width: '100%',
height: '100%',
position: 'relative',
}}
>
<StyledText style="displaySettingsLabel" css={{ pl: '20px' }}>
Line Height
</StyledText>
<HStack
distribution="between"
css={{
width: '100%',
alignItems: 'center',
alignSelf: 'center',
}}
>
<Button
style="plainIcon"
css={{ py: '0px', width: '60px' }}
onClick={() => {
const newLineHeight = Math.max(
props.readerSettings.lineHeight - 25,
100
)
props.readerSettings.setLineHeight(newLineHeight)
}}
>
<LineHeightDecreaseIcon
strokeColor={theme.colors.readerFont.toString()}
/>
</Button>
<TickedRangeSlider
min={100}
max={300}
step={25}
value={props.readerSettings.lineHeight}
onChange={(value) => {
props.readerSettings.setLineHeight(value)
}}
/>
<Button
style="plainIcon"
css={{ py: '0px', width: '60px' }}
onClick={() => {
const newLineHeight = Math.min(
props.readerSettings.lineHeight + 25,
300
)
props.readerSettings.setLineHeight(newLineHeight)
}}
>
<LineHeightIncreaseIcon
strokeColor={theme.colors.readerFont.toString()}
/>
</Button>
</HStack>
</VStack>
</>
)
}
function ThemeSelector(props: ReaderSettingsProps): JSX.Element {
const [currentTheme, setCurrentTheme] = useState(currentThemeName())
const isDark = useMemo(() => {
return currentTheme === 'Dark' || currentTheme === 'Darker'
}, [currentTheme])
return (
<VStack
css={{
px: '20px',
m: '0px',
pb: '10px',
width: '100%',
height: '100%',
}}
>
<StyledText style="displaySettingsLabel">Themes</StyledText>
<HStack
distribution="start"
css={{
gap: '16px',
pl: '5px',
}}
>
<Button
style="themeSwitch"
css={{
display: 'flex',
alignItems: 'center',
alignContent: 'center',
justifyContent: 'center',
width: '30px',
height: '30px',
background: '#F5F5F5',
borderRadius: '50%',
border: 'unset',
'&:hover': {
transform: 'scale(1.1)',
border: '2px solid #6A6968',
},
'&[data-state="selected"]': {
border: '2px solid #6A6968',
},
}}
data-state={isDark ? 'unselected' : 'selected'}
onClick={() => {
updateTheme(ThemeId.Light)
setCurrentTheme(currentThemeName())
}}
>
{!isDark && <Check color="#6A6968" size={15} weight="bold" />}
</Button>
<Button
style="themeSwitch"
css={{
display: 'flex',
alignItems: 'center',
alignContent: 'center',
justifyContent: 'center',
width: '30px',
height: '30px',
background: '#3B3938',
borderRadius: '50%',
border: 'unset',
'&:hover': {
transform: 'scale(1.1)',
border: '2px solid #6A6968',
},
'&[data-state="selected"]': {
border: '2px solid #6A6968',
},
}}
data-state={isDark ? 'selected' : 'unselected'}
onClick={() => {
updateTheme(ThemeId.Dark)
setCurrentTheme(currentThemeName())
}}
>
{isDark && <Check color="#F9D354" size={20} />}
</Button>
</HStack>
</VStack>
)
}

View file

@ -275,7 +275,7 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
revalidate()
},
[isSelected, props]
[isSelected, props, revalidate]
)
const filteredLabels = useMemo(() => {
@ -295,6 +295,23 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
const [focusedIndex, setFocusedIndex] = useState<number | undefined>(
undefined
)
const createLabelFromFilterText = useCallback(async () => {
const label = await createLabelMutation(
filterText,
randomLabelColorHex(),
''
)
if (label) {
showSuccessToast(`Created label ${label.name}`, {
position: 'bottom-right',
})
toggleLabel(label)
} else {
showErrorToast('Failed to create label', { position: 'bottom-right' })
}
}, [filterText, toggleLabel])
const handleKeyDown = useCallback(
async (event: React.KeyboardEvent<HTMLInputElement>) => {
const maxIndex = filteredLabels.length + 1
@ -346,24 +363,15 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
}
}
},
[filterText, filteredLabels, focusedIndex, isSelected, props]
)
const createLabelFromFilterText = useCallback(async () => {
const label = await createLabelMutation(
[
filterText,
randomLabelColorHex(),
''
)
if (label) {
showSuccessToast(`Created label ${label.name}`, {
position: 'bottom-right',
})
toggleLabel(label)
} else {
showErrorToast('Failed to create label', { position: 'bottom-right' })
}
}, [filterText, props, toggleLabel])
filteredLabels,
focusedIndex,
createLabelFromFilterText,
router,
toggleLabel,
]
)
return (
<VStack

View file

@ -56,7 +56,7 @@ export function SetLabelsModal(props: SetLabelsModalProps): JSX.Element {
props.onOpenChange(open)
},
[props, selectedLabels, previousSelectedLabels, setSelectedLabels]
[props, selectedLabels, previousSelectedLabels]
)
useEffect(() => {
@ -72,7 +72,7 @@ export function SetLabelsModal(props: SetLabelsModalProps): JSX.Element {
.catch((err) => {
console.log('error saving labels: ', err)
})
}, [selectedLabels, setPreviousSelectedLabels])
}, [props, selectedLabels, previousSelectedLabels, setPreviousSelectedLabels])
return (
<ModalRoot defaultOpen onOpenChange={onOpenChange}>

View file

@ -1,39 +0,0 @@
import { ShareModalLayout } from './ShareModal'
import { ShareArticleView } from '../../patterns/ShareArticleView'
type ShareArticleModalProps = {
url: string
title: string
imageURL?: string
author?: string
site?: string
description?: string
publishedAt: string
originalArticleUrl: string
onOpenChange: (open: boolean) => void
}
export function ShareArticleModal(
props: ShareArticleModalProps
): JSX.Element {
return (
<ShareModalLayout
url={props.url}
type='link'
modalTitle='Share Article'
title={props.title}
description={props.description}
onOpenChange={props.onOpenChange}
>
<ShareArticleView
url={props.url}
title={props.title}
imageURL={props.imageURL}
author={props.author}
publishedAt={props.publishedAt}
originalArticleUrl={props.originalArticleUrl}
description={props.description}
/>
</ShareModalLayout>
)
}

View file

@ -1,33 +0,0 @@
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { HighlightView } from '../../patterns/HighlightView'
import { ShareModalLayout } from './ShareModal'
type ShareHighlightModalProps = {
highlight: Highlight
url: string
title: string
author?: string
description?: string
onOpenChange: (open: boolean) => void
}
export function ShareHighlightModal(
props: ShareHighlightModalProps
): JSX.Element {
return (
<ShareModalLayout
url={props.url}
type='highlight'
modalTitle={`Share Highlight ${props.highlight?.annotation ? '& Note' : ''}`}
title={props.title}
description={props.description}
onOpenChange={props.onOpenChange}
>
<HighlightView
title={props.title}
author={props.author}
highlight={props.highlight}
/>
</ShareModalLayout>
)
}

View file

@ -1,5 +1,4 @@
import { Box } from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { theme } from '../../tokens/stitches.config'
type SkeletonArticleContainerProps = {
@ -10,7 +9,9 @@ type SkeletonArticleContainerProps = {
children?: React.ReactNode
}
export function SkeletonArticleContainer(props: SkeletonArticleContainerProps): JSX.Element {
export function SkeletonArticleContainer(
props: SkeletonArticleContainerProps
): JSX.Element {
const styles = {
margin: props.margin ?? 360,
fontSize: props.fontSize ?? 20,
@ -46,14 +47,14 @@ export function SkeletonArticleContainer(props: SkeletonArticleContainerProps):
'--blockquote-icon-font-size': '1.7rem',
'--figure-margin': '2.6875rem auto',
'--hr-margin': '2em',
margin: `30px 0px`,
margin: `0px`,
},
'@md': {
maxWidth: 1024 - (styles.margin),
maxWidth: 1024 - styles.margin,
},
'@lg': {
margin: `30px 0`,
maxWidth: 1024 - (styles.margin),
margin: `0`,
maxWidth: 1024 - styles.margin,
},
}}
>

View file

@ -1,161 +0,0 @@
import { ModalContent, ModalOverlay, ModalRoot } from '../../elements/ModalPrimitives'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { X, Check } from 'phosphor-react'
import { useState } from 'react'
import { showErrorToast } from '../../../lib/toastHelpers'
type ShareArticleModalProps = {
onOpenChange: (open: boolean) => void
submit: (option: string, reminder: boolean, msg: string) => void
}
enum ButtonPosition {
Top,
Middle,
Bottom,
Standalone,
}
type SnoozeOptionButtonProps = {
title: string
position: ButtonPosition
onClick: () => void
selected?: boolean
borderRadius?: string
}
function SnoozeOptionButton(props: SnoozeOptionButtonProps): JSX.Element {
let borderRadius = '0px'
let borderWidth = '1px'
switch (props.position) {
case ButtonPosition.Top:
borderWidth = '1px'
borderRadius = '8px 8px 0px 0px'
break
case ButtonPosition.Middle:
borderWidth = '0px 1px 0px 1px'
borderRadius = '0px'
break
case ButtonPosition.Bottom:
borderWidth = '1px'
borderRadius = '0px 0px 8px 8px'
break
case ButtonPosition.Standalone:
borderWidth = '1px'
borderRadius = '8px'
break
}
return (
<Button style='modalOption'
onClick={props.onClick}
css={{
borderRadius,
borderWidth,
background: props.selected ? '#FDFAEC' : undefined
}}>
<HStack css={{ pl: '16px', justifyContent: 'space-between', width: '100%', alignItems: 'center' }}>
<SpanBox>{props.title}</SpanBox>
{props.selected && (
<Check width={24} height={24} color='#E2B513' />
)}
</HStack>
</Button>)
}
export function SnoozeLinkModal(
props: ShareArticleModalProps
): JSX.Element {
const [sendReminder, setSendReminder] = useState(false)
const [snoozeOption, setSnoozeOption] = useState<string | undefined>(undefined)
const setOption = (option: string) => {
setSnoozeOption(option)
setSendReminder(true)
};
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
<ModalOverlay />
<ModalContent
onPointerDownOutside={(event) => {
event.preventDefault()
}}
css={{
m: '0px',
p: '0px',
width: '375px',
height: '388px',
overflow: 'auto',
background: 'white'
}}
>
<VStack css={{ px: '24px', pt: '22px', m: '0px' }}>
<HStack alignment='start' distribution='end' css={{ pb: '16px', width: '100%' }}>
<StyledText css={{ fontSize: '24px', m: '0px', mr: '0px', color: '#0A0806' }}>Snooze</StyledText>
<Button
style="ghost"
onClick={() => {
props.onOpenChange(false)
}}
tabIndex={-1}
css={{ marginLeft: 'auto', p: '0px' }}
>
<X color='black' width={24} height={24} />
</Button>
</HStack>
<VStack css={{ width: '100%', background: '#F8F8F8', borderRadius: '8px' }}>
<SnoozeOptionButton title='Snooze until tonight' position={ButtonPosition.Top} selected={snoozeOption == 'tonight'} onClick={() => setOption('tonight')} />
<SnoozeOptionButton title='Snooze until tomorrow' position={ButtonPosition.Middle} selected={snoozeOption == 'tomorrow'} onClick={() => setOption('tomorrow')} />
<SnoozeOptionButton title='Snooze until the weekend' position={ButtonPosition.Bottom} selected={snoozeOption == 'weekend'} onClick={() => setOption('weekend')} />
</VStack>
<Box css={{ mt: '16px', width: '100%', background: '#F8F8F8', }}>
<SnoozeOptionButton title='Send me a reminder' position={ButtonPosition.Standalone} selected={sendReminder}
onClick={() => {
setSendReminder(!sendReminder)
}}
/>
</Box>
<HStack css={{ mt: '16px', justifyContent: 'space-between', width: '100%' }}>
<Button
title='Cancel'
css={{ fontSize: '16px', width: '158px', height: '52px', background: 'unset', border: 'unset', color: '#0A0806', fontWeight: '400' }}
onClick={() => {
props.onOpenChange(false)
}}
>Cancel</Button>
<Button
style='ctaDarkYellow'
title='Save'
css={{ fontSize: '16px', width: '158px', height: '52px', marginLeft: 'auto', color: '#0A0806', fontWeight: '400' }}
onClick={() => {
if (snoozeOption) {
let msg = 'Link snoozed until '
switch (snoozeOption) {
case 'tonight':
msg += 'tonight.'
break
case 'tomorrow':
msg += 'tomorrow.'
break
case 'weekend':
msg += 'the weekend.'
break
}
props.submit(snoozeOption, sendReminder, msg)
props.onOpenChange(false)
} else {
showErrorToast('No option selected', { position: 'bottom-right' })
}
}}
>Save</Button>
</HStack>
</VStack>
</ModalContent>
</ModalRoot>
)
}

View file

@ -0,0 +1,174 @@
import {
ArchiveBox,
DotsThreeOutline,
HighlighterCircle,
Info,
TagSimple,
TextAa,
Trash,
Tray,
} from 'phosphor-react'
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { Button } from '../../elements/Button'
import { HStack } from '../../elements/LayoutPrimitives'
import { TooltipWrapped } from '../../elements/Tooltip'
import { theme } from '../../tokens/stitches.config'
import { ReaderDropdownMenu } from '../../patterns/ReaderDropdownMenu'
export type ArticleActionsMenuLayout = 'top' | 'side'
type ArticleActionsMenuProps = {
article?: ArticleAttributes
layout: ArticleActionsMenuLayout
showReaderDisplaySettings?: boolean
articleActionHandler: (action: string, arg?: unknown) => void
}
export function VerticalArticleActionsMenu(
props: ArticleActionsMenuProps
): JSX.Element {
return (
<>
<HStack
distribution="end"
alignment="center"
css={{
width: '100%',
gap: '30px',
}}
>
<Button
style="articleActionIcon"
onClick={() => props.articleActionHandler('setLabels')}
css={{
display: 'flex',
alignItems: 'center',
}}
>
<TagSimple size={24} color={theme.colors.thHighContrast.toString()} />
</Button>
<Button
style="articleActionIcon"
onClick={() => props.articleActionHandler('showHighlights')}
css={{
display: 'flex',
alignItems: 'center',
}}
>
<TooltipWrapped
tooltipContent="View Highlights"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<HighlighterCircle
size={24}
color={theme.colors.thHighContrast.toString()}
/>
</TooltipWrapped>
</Button>
<Button
style="articleActionIcon"
onClick={() => props.articleActionHandler('showEditModal')}
css={{
display: 'flex',
alignItems: 'center',
'@mdDown': {
display: 'none',
},
}}
>
<TooltipWrapped
tooltipContent="Edit title & description"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<Info size={24} color={theme.colors.thHighContrast.toString()} />
</TooltipWrapped>
</Button>
<Button
style="articleActionIcon"
onClick={() => {
props.articleActionHandler('delete')
}}
css={{
display: 'flex',
alignItems: 'center',
'@mdDown': {
display: 'none',
},
}}
>
<TooltipWrapped
tooltipContent="Delete"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<Trash size={24} color={theme.colors.thHighContrast.toString()} />
</TooltipWrapped>
</Button>
{!props.article?.isArchived ? (
<Button
style="articleActionIcon"
onClick={() => props.articleActionHandler('archive')}
css={{
display: 'flex',
alignItems: 'center',
}}
>
<TooltipWrapped
tooltipContent="Archive"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<ArchiveBox
size={24}
color={theme.colors.thHighContrast.toString()}
/>
</TooltipWrapped>
</Button>
) : (
<Button
style="articleActionIcon"
onClick={() => props.articleActionHandler('unarchive')}
css={{
display: 'flex',
alignItems: 'center',
}}
>
<TooltipWrapped
tooltipContent="Unarchive"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<Tray size={24} color={theme.colors.thHighContrast.toString()} />
</TooltipWrapped>
</Button>
)}
<Button
style="articleActionIcon"
onClick={() => props.articleActionHandler('editDisplaySettings')}
css={{
display: 'flex',
alignItems: 'center',
}}
>
<TooltipWrapped
tooltipContent="Edit title & description"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<TextAa size={24} color={theme.colors.thHighContrast.toString()} />
</TooltipWrapped>
</Button>
<ReaderDropdownMenu
triggerElement={
<DotsThreeOutline
size={24}
color={theme.colors.thHighContrast.toString()}
/>
}
articleActionHandler={props.articleActionHandler}
/>
</HStack>
</>
)
}

View file

@ -5,7 +5,6 @@ import { useEffect, useState } from 'react'
import { BorderedFormInput, FormLabel } from '../../elements/FormElements'
import { fetchEndpoint } from '../../../lib/appConfig'
import { logoutMutation } from '../../../lib/networking/mutations/logoutMutation'
import { styled } from '@stitches/react'
import { useRouter } from 'next/router'
import { parseErrorCodes } from '../../../lib/queryParamParser'
import { formatMessage } from '../../../locales/en/messages'
@ -15,8 +14,9 @@ export function EmailLogin(): JSX.Element {
const router = useRouter()
const [email, setEmail] = useState<string | undefined>(undefined)
const [password, setPassword] = useState<string | undefined>(undefined)
const [errorMessage, setErrorMessage] =
useState<string | undefined>(undefined)
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined
)
useEffect(() => {
if (!router.isReady) return

View file

@ -7,7 +7,6 @@ import { TermAndConditionsFooter } from '../LoginForm'
import { fetchEndpoint } from '../../../lib/appConfig'
import { useValidateUsernameQuery } from '../../../lib/networking/queries/useValidateUsernameQuery'
import { logoutMutation } from '../../../lib/networking/mutations/logoutMutation'
import { styled } from '@stitches/react'
import { useRouter } from 'next/router'
import { formatMessage } from '../../../locales/en/messages'
import { parseErrorCodes } from '../../../lib/queryParamParser'
@ -19,10 +18,12 @@ export function EmailSignup(): JSX.Element {
const [password, setPassword] = useState<string | undefined>(undefined)
const [fullname, setFullname] = useState<string | undefined>(undefined)
const [username, setUsername] = useState<string | undefined>(undefined)
const [debouncedUsername, setDebouncedUsername] =
useState<string | undefined>(undefined)
const [errorMessage, setErrorMessage] =
useState<string | undefined>(undefined)
const [debouncedUsername, setDebouncedUsername] = useState<
string | undefined
>(undefined)
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined
)
useEffect(() => {
if (!router.isReady) return
@ -165,7 +166,7 @@ export function EmailSignup(): JSX.Element {
style={'ctaOutlineYellow'}
css={{ color: '$omnivoreGray', borderColor: 'rgba(0, 0, 0, 0.06)' }}
type="button"
onClick={async (event) => {
onClick={async () => {
window.localStorage.removeItem('authVerified')
window.localStorage.removeItem('authToken')
try {

View file

@ -1,7 +1,6 @@
import { Box, HStack } from '../../elements/LayoutPrimitives'
import type { LoginFormProps } from '../LoginForm'
export function ResetSent(props: LoginFormProps): JSX.Element {
export function ResetSent(): JSX.Element {
return (
<>
<HStack

View file

@ -1,7 +1,6 @@
import { Box, HStack } from '../../elements/LayoutPrimitives'
import type { LoginFormProps } from '../LoginForm'
export function VerifyEmail(props: LoginFormProps): JSX.Element {
export function VerifyEmail(): JSX.Element {
return (
<>
<HStack

View file

@ -5,14 +5,10 @@ import {
ModalTitleBar,
ModalButtonBar,
} from '../../elements/ModalPrimitives'
import { VStack, HStack, Box, SpanBox } from '../../elements/LayoutPrimitives'
import { VStack, Box } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { CrossIcon } from '../../elements/images/CrossIcon'
import { theme } from '../../tokens/stitches.config'
import { FormInput } from '../../elements/FormElements'
import { useState, useCallback } from 'react'
import { createArticleFromURLMutation } from '../../../lib/networking/mutations/createArticleFromURLMutation'
import { saveUrlMutation } from '../../../lib/networking/mutations/saveUrlMutation'
import toast from 'react-hot-toast'
import { showErrorToast } from '../../../lib/toastHelpers'
@ -24,42 +20,51 @@ type AddLinkModalProps = {
export function AddLinkModal(props: AddLinkModalProps): JSX.Element {
const [link, setLink] = useState('')
const handleLinkSubmission = useCallback(async (link: string) => {
const result = await saveUrlMutation(link)
// const result = await saveUrlMutation(link)
if (result && result.jobId) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
toast((t) => (
<Box>
Link Saved
<span style={{ padding: '16px' }} />
<Button
style="ctaDarkYellow"
autoFocus
onClick={() => {
window.location.href = `/article/sr/${result.jobId}`
}}
>
Read Now
</Button>
</Box>
), { position: 'bottom-right' })
} else {
showErrorToast('Error saving link', { position: 'bottom-right' })
}
}, [link])
const handleLinkSubmission = useCallback(
async (link: string) => {
const result = await saveUrlMutation(link)
// const result = await saveUrlMutation(link)
if (result && result.jobId) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
toast(
() => (
<Box>
Link Saved
<span style={{ padding: '16px' }} />
<Button
style="ctaDarkYellow"
autoFocus
onClick={() => {
window.location.href = `/article/sr/${result.jobId}`
}}
>
Read Now
</Button>
</Box>
),
{ position: 'bottom-right' }
)
} else {
showErrorToast('Error saving link', { position: 'bottom-right' })
}
},
[link]
)
const validateLink = useCallback((link: string) => {
try {
const url = new URL(link)
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
const validateLink = useCallback(
(link: string) => {
try {
const url = new URL(link)
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return false
}
} catch (e) {
return false
}
} catch (e) {
return false
}
return true
}, [link])
return true
},
[link]
)
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
@ -110,7 +115,10 @@ export function AddLinkModal(props: AddLinkModalProps): JSX.Element {
fontSize: '14px',
}}
/>
<ModalButtonBar onOpenChange={props.onOpenChange} acceptButtonLabel="Add Link" />
<ModalButtonBar
onOpenChange={props.onOpenChange}
acceptButtonLabel="Add Link"
/>
</form>
</Box>
</VStack>

View file

@ -0,0 +1,357 @@
import {
ModalRoot,
ModalContent,
ModalOverlay,
} from '../../elements/ModalPrimitives'
import { VStack, HStack, Box, SpanBox } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { FormInput } from '../../elements/FormElements'
import { useCallback, useState } from 'react'
import { LibraryItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { StyledTextArea } from '../../elements/StyledTextArea'
import { updatePageMutation } from '../../../lib/networking/mutations/updatePageMutation'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import dayjs, { Dayjs } from 'dayjs'
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { CloseButton } from '../../elements/CloseButton'
type EditLibraryItemModalProps = {
onOpenChange: (open: boolean) => void
item: LibraryItem
updateItem: (item: LibraryItem) => Promise<void>
}
export function EditLibraryItemModal(
props: EditLibraryItemModalProps
): JSX.Element {
const onSave = useCallback(
(
title: string,
author: string | undefined,
description: string,
savedAt: Dayjs,
publishedAt: Dayjs | undefined
) => {
;(async () => {
if (title !== '') {
const res = await updatePageMutation({
pageId: props.item.node.id,
title,
description,
byline: author,
savedAt: savedAt.toISOString(),
publishedAt: publishedAt ? publishedAt.toISOString() : undefined,
})
if (res) {
await props.updateItem({
cursor: props.item.cursor,
node: {
...props.item.node,
title: title,
author: author,
description: description,
},
})
showSuccessToast('Link updated succesfully', {
position: 'bottom-right',
})
props.onOpenChange(false)
} else {
showErrorToast('There was an error updating your link', {
position: 'bottom-right',
})
}
} else {
showErrorToast('Title must be a non-empty value', {
position: 'bottom-right',
})
}
})()
},
[props]
)
return (
<EditItemModal
title={props.item.node.title}
author={props.item.node.author}
description={props.item.node.description}
savedAt={dayjs(props.item.node.savedAt)}
publishedAt={
props.item.node.publishedAt
? dayjs(props.item.node.publishedAt)
: undefined
}
onOpenChange={props.onOpenChange}
onSave={onSave}
/>
)
}
type EditArticleModalProps = {
onOpenChange: (open: boolean) => void
article: ArticleAttributes
updateArticle: (
title: string,
author: string | undefined,
description: string,
savedAt: string,
publishedAt: string | undefined
) => void
}
export function EditArticleModal(props: EditArticleModalProps): JSX.Element {
const onSave = useCallback(
(
title: string,
author: string | undefined,
description: string,
savedAt: Dayjs,
publishedAt: Dayjs | undefined
) => {
;(async () => {
if (title !== '') {
const res = await updatePageMutation({
pageId: props.article.id,
title,
description,
byline: author,
savedAt: savedAt.toISOString(),
publishedAt: publishedAt ? publishedAt.toISOString() : undefined,
})
if (res) {
props.updateArticle(
title,
author,
description,
savedAt.toISOString(),
publishedAt ? publishedAt.toISOString() : undefined
)
showSuccessToast('Link updated succesfully', {
position: 'bottom-right',
})
props.onOpenChange(false)
} else {
showErrorToast('There was an error updating your link', {
position: 'bottom-right',
})
}
} else {
showErrorToast('Title must be a non-empty value', {
position: 'bottom-right',
})
}
})()
},
[props]
)
return (
<EditItemModal
title={props.article.title}
author={props.article.author}
description={props.article.description || ''}
savedAt={dayjs(props.article.savedAt)}
publishedAt={
props.article.publishedAt ? dayjs(props.article.publishedAt) : undefined
}
onOpenChange={props.onOpenChange}
onSave={onSave}
/>
)
}
type EditItemModalProps = {
title: string
author: string | undefined
description: string
savedAt: Dayjs
publishedAt: Dayjs | undefined
onOpenChange: (open: boolean) => void
onSave: (
title: string,
author: string | undefined,
description: string,
savedAt: Dayjs,
publishedAt: Dayjs | undefined
) => void
}
function EditItemModal(props: EditItemModalProps): JSX.Element {
const [title, setTitle] = useState(props.title)
const [author, setAuthor] = useState(props.author)
const [savedAt, setSavedAt] = useState(props.savedAt)
const [publishedAt, setPublishedAt] = useState(props.publishedAt)
const [description, setDescription] = useState(props.description)
const titleStyle = {
mt: '22px',
mb: '2px',
fontFamily: '$display',
fontWeight: '600',
fontSize: '11px',
color: '#898989',
}
const inputStyle = {
mt: '1px',
borderRadius: '5px',
border: '1px solid $thBorderColor',
fontFamily: 'Inter',
fontWeight: '500',
fontSize: '16px',
height: '38px',
p: '5px',
color: '$thTextContrast2',
'&:focus': {
outline: 'none !important',
border: '1px solid $omnivoreCtaYellow',
},
}
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange} css={{}}>
<ModalOverlay />
<ModalContent
css={{ bg: '$grayBg', p: '20px', maxWidth: '420px' }}
onInteractOutside={() => {
// remove focus from modal
;(document.activeElement as HTMLElement).blur()
}}
>
<VStack distribution="start" css={{ p: '0px' }}>
<Header onOpenChange={props.onOpenChange} />
<Box css={{ width: '100%' }}>
<form
onSubmit={(event) => {
event.preventDefault()
}}
>
<HStack distribution="start" css={{ width: '100%' }}>
<VStack css={{ width: '45%' }}>
<StyledText css={titleStyle}>SAVED AT:</StyledText>
<FormInput
type="datetime-local"
value={props.savedAt.format('YYYY-MM-DDThh:mm')}
placeholder="Edit Date"
onChange={(event) => {
const dateStr = event.target.value
setSavedAt(dayjs(dateStr))
}}
css={{
...inputStyle,
fontSize: '14px',
}}
/>
</VStack>
<VStack css={{ width: '45%', marginLeft: 'auto' }}>
<StyledText css={titleStyle}>PUBLISHED AT</StyledText>
<FormInput
type="datetime-local"
value={
props.publishedAt
? props.publishedAt.format('YYYY-MM-DDThh:mm')
: undefined
}
placeholder="Edit Published Date"
onChange={(event) => {
const dateStr = event.target.value
setPublishedAt(dayjs(dateStr))
}}
css={{
...inputStyle,
fontSize: '14px',
}}
/>
</VStack>
</HStack>
<StyledText css={titleStyle}>TITLE</StyledText>
<FormInput
type="text"
value={title}
autoFocus
placeholder="Edit Title"
onChange={(event) => setTitle(event.target.value)}
onFocus={(event) => {
event.target.select()
}}
css={inputStyle}
/>
<StyledText css={titleStyle}>AUTHOR</StyledText>
<FormInput
type="author"
value={author}
placeholder="Edit Author"
onChange={(event) => setAuthor(event.target.value)}
onFocus={(event) => {
event.target.select()
}}
css={inputStyle}
/>
<StyledText css={titleStyle}>DESCRIPTION</StyledText>
<StyledTextArea
css={{
...inputStyle,
mt: '2px',
width: '100%',
height: '120px',
}}
placeholder="Edit Description"
value={description}
onChange={(event) => setDescription(event.target.value)}
onFocus={(event) => {
event.target.select()
}}
maxLength={4000}
/>
<HStack distribution="end" css={{ mt: '12px', width: '100%' }}>
<Button
onClick={() => props.onOpenChange(false)}
style="cancelGeneric"
css={{ mr: '5px' }}
>
Cancel
</Button>
<Button
onClick={() => {
props.onSave(
title,
author,
description,
savedAt,
publishedAt
)
}}
style="ctaDarkYellow"
css={{ mb: '0px' }}
>
Save Changes
</Button>
</HStack>
</form>
</Box>
</VStack>
</ModalContent>
</ModalRoot>
)
}
type HeaderProps = {
onOpenChange: (open: boolean) => void
}
function Header(props: HeaderProps): JSX.Element {
return (
<HStack distribution="start" alignment="center" css={{ width: '100%' }}>
<StyledText style="modalHeadline">Edit Title & Description</StyledText>
<SpanBox css={{ marginLeft: 'auto' }}>
<CloseButton close={() => props.onOpenChange(false)} />
</SpanBox>
</HStack>
)
}

View file

@ -1,173 +0,0 @@
import {
ModalRoot,
ModalContent,
ModalOverlay,
} from '../../elements/ModalPrimitives'
import { VStack, HStack, Box } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { CrossIcon } from '../../elements/images/CrossIcon'
import { theme } from '../../tokens/stitches.config'
import { FormInput } from '../../elements/FormElements'
import { useState } from 'react'
import { LibraryItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { StyledTextArea } from '../../elements/StyledTextArea'
import { updatePageMutation } from '../../../lib/networking/mutations/updatePageMutation'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
type EditTitleModalProps = {
onOpenChange: (open: boolean) => void
item: LibraryItem
updateItem: (item: LibraryItem) => Promise<void>
}
export function EditTitleModal(props: EditTitleModalProps): JSX.Element {
const [title, setTitle] = useState(props.item.node.title)
const [author, setAuthor] = useState(props.item.node.author)
const [description, setDescription] = useState(props.item.node.description)
const handleUpdateTitle = async () => {
if (title !== '') {
const res = await updatePageMutation({
pageId: props.item.node.id,
title,
description,
byline: author,
})
if (res) {
await props.updateItem({
cursor: props.item.cursor,
node: {
...props.item.node,
title: title,
author: author,
description: description,
},
})
showSuccessToast('Link updated succesfully', {
position: 'bottom-right',
})
props.onOpenChange(false)
} else {
showErrorToast('There was an error updating your link', {
position: 'bottom-right',
})
}
} else {
showErrorToast('Title must be a non-empty value', {
position: 'bottom-right',
})
}
}
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
<ModalOverlay />
<ModalContent
css={{ bg: '$grayBg', pt: '0px' }}
onInteractOutside={() => {
// remove focus from modal
;(document.activeElement as HTMLElement).blur()
}}
>
<VStack distribution="start" css={{ p: '$2' }}>
<HStack
distribution="between"
alignment="center"
css={{ width: '100%', mt: '4px' }}
>
<StyledText style="modalHeadline">
Edit Title and Description
</StyledText>
<Button
css={{ p: '10px', cursor: 'pointer', pt: '2px' }}
style="ghost"
onClick={() => {
props.onOpenChange(false)
}}
>
<CrossIcon
size={11}
strokeColor={theme.colors.grayTextContrast.toString()}
/>
</Button>
</HStack>
<StyledText css={{ mt: '22px', mb: '6px' }}>Title</StyledText>
<Box css={{ width: '100%' }}>
<form
onSubmit={(event) => {
event.preventDefault()
}}
>
<FormInput
type="text"
value={title}
autoFocus
placeholder="Edit Title"
onChange={(event) => setTitle(event.target.value)}
css={{
borderRadius: '8px',
border: '1px solid $grayTextContrast',
width: '100%',
p: '$2',
}}
/>
<StyledText css={{ mt: '22px', mb: '6px' }}>Author</StyledText>
<FormInput
type="author"
value={author}
autoFocus
placeholder="Edit Author"
onChange={(event) => setAuthor(event.target.value)}
css={{
borderRadius: '8px',
border: '1px solid $grayTextContrast',
width: '100%',
p: '$2',
}}
/>
<StyledText css={{ mt: '22px', mb: '6px' }}>
Description
</StyledText>
<Box
css={{
border: '1px solid $grayTextContrast',
borderRadius: '8px',
}}
>
<StyledTextArea
css={{
p: '14px',
height: '$6',
width: '100%',
}}
placeholder="Edit Description"
value={description}
onChange={(event) => setDescription(event.target.value)}
maxLength={4000}
/>
</Box>
<HStack distribution="end" css={{ mt: '12px', width: '100%' }}>
<Button
onClick={() => props.onOpenChange(false)}
style="ctaOutlineYellow"
css={{ mr: '16px' }}
>
Cancel
</Button>
<Button
onClick={handleUpdateTitle}
style="ctaDarkYellow"
css={{ mb: '0px' }}
>
Save
</Button>
</HStack>
</form>
</Box>
</VStack>
</ModalContent>
</ModalRoot>
)
}

View file

@ -1,15 +1,8 @@
// There aren't any discussions.
// You can open a new discussion to ask questions about this repository or get help.
import Link from 'next/link'
import { Book } from 'phosphor-react'
import { LibraryItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { Button } from '../../elements/Button'
import { Box, VStack } from '../../elements/LayoutPrimitives'
import { VStack } from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { LinkedItemCardAction } from '../../patterns/LibraryCards/CardTypes'
import { theme } from '../../tokens/stitches.config'
type EmptyLibraryProps = {
@ -18,19 +11,33 @@ type EmptyLibraryProps = {
export function EmptyLibrary(props: EmptyLibraryProps): JSX.Element {
return (
<VStack alignment="center" distribution="center" css={{ color: '$grayTextContrast', textAlign: 'center', paddingTop: '88px' }}>
<VStack
alignment="center"
distribution="center"
css={{
color: '$grayTextContrast',
textAlign: 'center',
paddingTop: '88px',
}}
>
<Book size={44} color={theme.colors.grayTextContrast.toString()} />
<StyledText style="fixedHeadline" css={{ color: '$grayTextContrast' }}>
No results found.
</StyledText>
<StyledText style="fixedHeadline" css={{ color: '$grayTextContrast' }}>
No results found.
</StyledText>
<StyledText style="footnote" css={{ color: '$grayTextContrast' }}>
You can add a link or read more about Omnivore&apos;s <Link href="/help/search">advanced search</Link>.
</StyledText>
<Button style="ctaDarkYellow" onClick={() => { props.onAddLinkClicked() }}>
Add Link
</Button>
<StyledText style="footnote" css={{ color: '$grayTextContrast' }}>
You can add a link or read more about Omnivore&apos;s{' '}
<Link href="/help/search">advanced search</Link>.
</StyledText>
<Button
style="ctaDarkYellow"
onClick={() => {
props.onAddLinkClicked()
}}
>
Add Link
</Button>
</VStack>
)
}

View file

@ -0,0 +1,18 @@
import { Box } from '../../elements/LayoutPrimitives'
export const HEADER_HEIGHT = '105px'
export const MOBILE_HEADER_HEIGHT = '70px'
export function HeaderSpacer(): JSX.Element {
return (
<Box
css={{
height: HEADER_HEIGHT,
bg: '$grayBase',
'@mdDown': {
height: MOBILE_HEADER_HEIGHT,
},
}}
></Box>
)
}

View file

@ -0,0 +1,275 @@
import { styled } from '@stitches/react'
import { useRouter } from 'next/router'
import { DotsThreeVertical } from 'phosphor-react'
import { Fragment, useCallback, useMemo, useState } from 'react'
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
import { setLabelsForHighlight } from '../../../lib/networking/mutations/setLabelsForHighlight'
import { LibraryItemNode } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
import { HighlightNoteTextEditArea } from '../../elements/HighlightNoteTextEditArea'
import { LabelChip } from '../../elements/LabelChip'
import {
Blockquote,
Box,
HStack,
SpanBox,
VStack,
} from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { ConfirmationModal } from '../../patterns/ConfirmationModal'
import { SetLabelsModal } from '../article/SetLabelsModal'
type HighlightItemProps = {
highlight: Highlight
viewer: UserBasicData | undefined
item: LibraryItemNode
}
const StyledQuote = styled(Blockquote, {
margin: '0px',
fontSize: '16px',
fontFamily: '$inter',
fontWeight: '500',
lineHeight: '1.50',
color: '$thHighContrast',
paddingLeft: '15px',
borderLeft: '2px solid $omnivoreCtaYellow',
})
export function HighlightItem(props: HighlightItemProps): JSX.Element {
const router = useRouter()
const [hover, setHover] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const lines = useMemo(
() => props.highlight.quote.split('\n'),
[props.highlight.quote]
)
const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] =
useState<undefined | string>(undefined)
const [labelsTarget, setLabelsTarget] = useState<Highlight | undefined>(
undefined
)
const [, updateState] = useState({})
return (
<>
<HStack
css={{ width: '100%', py: '20px', cursor: 'pointer' }}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<VStack
css={{
gap: '10px',
height: '100%',
width: '100%',
wordBreak: 'break-word',
overflow: 'clip',
}}
alignment="start"
distribution="start"
>
<StyledQuote
onClick={(event) => {
if (router && props.viewer) {
const dest = `/${props.viewer}/${props.item.slug}#${props.highlight.id}`
router.push(dest)
}
event.preventDefault()
}}
>
<SpanBox css={{ p: '1px', borderRadius: '2px' }}>
{lines.map((line: string, index: number) => (
<Fragment key={index}>
{line}
{index !== lines.length - 1 && (
<>
<br />
<br />
</>
)}
</Fragment>
))}
</SpanBox>
</StyledQuote>
<Box css={{ display: 'block' }}>
{props.highlight.labels?.map((label: Label, index: number) => (
<LabelChip
key={index}
text={label.name || ''}
color={label.color}
/>
))}
</Box>
{!isEditing && (
<StyledText
css={{
borderRadius: '6px',
bg: '#EBEBEB',
p: '10px',
width: '100%',
marginTop: '5px',
color: '#3D3D3D',
}}
onClick={() => setIsEditing(true)}
>
{props.highlight.annotation
? props.highlight.annotation
: 'Add your notes...'}
</StyledText>
)}
{isEditing && (
<HighlightNoteTextEditArea
setIsEditing={setIsEditing}
highlight={props.highlight}
// eslint-disable-next-line @typescript-eslint/no-empty-function
updateHighlight={() => {}}
/>
)}
</VStack>
<SpanBox
css={{
marginLeft: 'auto',
width: '20px',
visibility: hover ? 'unset' : 'hidden',
'@media (hover: none)': {
visibility: 'unset',
},
}}
>
<HighlightsMenu
highlight={props.highlight}
setLabelsTarget={setLabelsTarget}
setShowConfirmDeleteHighlightId={setShowConfirmDeleteHighlightId}
/>
</SpanBox>
</HStack>
{showConfirmDeleteHighlightId && (
<ConfirmationModal
message={'Are you sure you want to delete this highlight?'}
onAccept={async () => {
setShowConfirmDeleteHighlightId(undefined)
const result = await deleteHighlightMutation(
showConfirmDeleteHighlightId
)
if (result) {
showSuccessToast('Highlight deleted')
} else {
showErrorToast('Error deleting highlight')
}
}}
onOpenChange={() => setShowConfirmDeleteHighlightId(undefined)}
/>
)}
{labelsTarget && (
<SetLabelsModal
provider={labelsTarget}
onOpenChange={function (open: boolean): void {
setLabelsTarget(undefined)
}}
onLabelsUpdated={function (labels: Label[]): void {
updateState({})
}}
save={function (labels: Label[]): Promise<Label[] | undefined> {
const result = setLabelsForHighlight(
labelsTarget.id,
labels.map((label) => label.id)
)
return result
}}
/>
)}
</>
)
}
type HighlightsMenuProps = {
highlight: Highlight
setLabelsTarget: (target: Highlight) => void
setShowConfirmDeleteHighlightId: (set: string) => void
}
function HighlightsMenu(props: HighlightsMenuProps): JSX.Element {
const copyHighlight = useCallback(() => {
;(async () => {
await navigator.clipboard.writeText(props.highlight.quote)
showSuccessToast('Highlight copied')
})()
}, [props.highlight])
const exportHighlight = useCallback(() => {
;(async () => {
const markdown = highlightAsMarkdown(props.highlight)
await navigator.clipboard.writeText(markdown)
showSuccessToast('Highlight copied')
})()
}, [props.highlight])
return (
<Dropdown
triggerElement={
<Box
css={{
display: 'flex',
height: '20px',
width: '20px',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '1000px',
'&:hover': {
bg: '#898989',
},
}}
>
<DotsThreeVertical size={20} color="#EBEBEB" weight="bold" />
</Box>
}
>
<DropdownOption
onSelect={async () => {
copyHighlight()
}}
title="Copy"
/>
<DropdownOption
onSelect={() => {
props.setLabelsTarget(props.highlight)
}}
title="Labels"
/>
<DropdownOption
onSelect={() => {
exportHighlight()
}}
title="Delete"
/>
</Dropdown>
)
}
export function highlightAsMarkdown(highlight: Highlight) {
let buffer = `> ${highlight.quote}`
if (highlight.annotation) {
buffer += `\n\n${highlight.annotation}`
}
buffer += '\n'
return buffer
}
export function highlightsAsMarkdown(highlights: Highlight[]) {
return highlights
.map((highlight) => {
return highlightAsMarkdown(highlight)
})
.join('\n\n')
}

View file

@ -0,0 +1,369 @@
import { DotsThreeVertical, HighlighterCircle } from 'phosphor-react'
import { useCallback, useEffect, useState } from 'react'
import { Toaster } from 'react-hot-toast'
import { LibraryItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import {
MetaStyle,
timeAgo,
} from '../../patterns/LibraryCards/LibraryCardStyles'
import { LibraryHighlightGridCard } from '../../patterns/LibraryCards/LibraryHighlightGridCard'
import { HighlightItem, highlightsAsMarkdown } from './HighlightItem'
type HighlightItemsLayoutProps = {
items: LibraryItem[]
viewer: UserBasicData | undefined
gridContainerRef: React.RefObject<HTMLDivElement>
}
export function HighlightItemsLayout(
props: HighlightItemsLayoutProps
): JSX.Element {
const [currentItem, setCurrentItem] = useState<LibraryItem | undefined>(
undefined
)
useEffect(() => {
// Only set the current item on larger screens
if (window.innerWidth >= 992 /* lgDown */) {
if (!currentItem && props.items.length > 0) {
setCurrentItem(props.items[0])
}
}
}, [currentItem, setCurrentItem, props.items])
return (
<>
<HStack
css={{
width: '100%',
height: '100%',
bg: '$thBackground2',
}}
distribution="start"
alignment="start"
>
<Toaster />
<VStack
css={{
width: '430px',
height: '100%',
bg: '$thBackground',
'@lgDown': {
width: '100%',
},
}}
distribution="start"
alignment="start"
>
<HStack
css={{
width: 'calc(100% - 35px)',
height: '55px',
mx: '20px',
borderBottom: '1px solid $thBorderColor',
}}
alignment="center"
distribution="start"
>
{/* <Box
css={{
display: 'flex',
height: '20px',
width: '20px',
marginLeft: 'auto',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '1000px',
'&:hover': {
bg: '#898989',
},
}}
>
<DotsThreeVertical size={200} color="#898989" weight="bold" />
</Box> */}
</HStack>
{props.items.map((linkedItem) => (
<Box
className="linkedItemCard"
data-testid="linkedItemCard"
id={linkedItem.node.id}
tabIndex={0}
key={linkedItem.node.id}
css={{
width: '100%',
height: '100%',
px: '15px',
}}
onClick={(event) => {
setCurrentItem(linkedItem)
event.preventDefault()
}}
>
{props.viewer && (
<LibraryItemCard
item={linkedItem}
viewer={props.viewer}
selected={currentItem?.node.id == linkedItem.node.id}
/>
)}
</Box>
))}
</VStack>
{currentItem && (
<>
<SpanBox
css={{
display: 'flex',
height: '100%',
flexGrow: '1',
'@lgDown': {
display: 'none',
flexGrow: 'unset',
},
}}
>
<HighlightList item={currentItem} viewer={props.viewer} />
</SpanBox>
</>
)}
</HStack>
</>
)
}
type HighlightTitleCardProps = {
item: LibraryItem
viewer: UserBasicData
selected: boolean
}
function LibraryItemCard(props: HighlightTitleCardProps): JSX.Element {
return (
<>
<SpanBox css={{ display: 'none', '@lgDown': { display: 'flex' } }}>
<LibraryHighlightGridCard
item={props.item.node}
viewer={props.viewer}
/>
</SpanBox>
<SpanBox css={{ display: 'none', '@lg': { display: 'flex' } }}>
<HighlightTitleCard {...props} />
</SpanBox>
</>
)
}
function HighlightTitleCard(props: HighlightTitleCardProps): JSX.Element {
return (
<HStack
css={{
height: '100%',
width: '100%',
py: '10px',
borderBottom: '1px solid $thBorderColor',
}}
distribution="start"
>
<HStack
css={{
width: '100%',
height: '100%',
py: '15px',
px: '15px',
borderRadius: '10px',
bg: props.selected ? '$thBackground2' : 'unset',
}}
>
<VStack
css={{
width: '100%',
height: '100%',
borderRadius: '5px',
}}
>
<HStack css={MetaStyle} distribution="start">
<Box>
{timeAgo(props.item.node.savedAt)}
{` `}
{props.item.node.wordsCount ?? 0 > 0
? `${Math.max(
1,
Math.round((props.item.node.wordsCount ?? 0) / 235)
)} min read`
: null}
{props.item.node.readingProgressPercent ?? 0 > 0 ? (
<>
{``}
<SpanBox css={{ color: '#55B938' }}>
{`${Math.round(props.item.node.readingProgressPercent)}%`}
</SpanBox>
</>
) : null}
</Box>
</HStack>
<Box
css={{
mt: '5px',
color: '$thTextContrast2',
fontSize: '16px',
fontWeight: '500',
lineHeight: '1.50',
fontFamily: '$display',
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
}}
>
{props.item.node.title}
</Box>
</VStack>
<HighlightCountChip
selected={props.selected}
count={props.item.node.highlights?.length ?? 0}
/>
</HStack>
</HStack>
)
}
type HighlightListProps = {
item: LibraryItem
viewer: UserBasicData | undefined
}
function HighlightList(props: HighlightListProps): JSX.Element {
const exportHighlights = useCallback(() => {
;(async () => {
if (!props.item.node.highlights) {
showErrorToast('No highlights to export')
return
}
const markdown = highlightsAsMarkdown(props.item.node.highlights)
await navigator.clipboard.writeText(markdown)
showSuccessToast('Highlight copied')
})()
}, [props.item.node.highlights])
return (
<HStack
css={{
m: '20px',
height: '100%',
flexGrow: '1',
}}
distribution="center"
alignment="start"
>
<VStack
css={{
width: '425px',
height: '100%',
borderRadius: '6px',
}}
alignment="start"
distribution="start"
>
<HStack
css={{
width: '100%',
height: '100%',
pt: '25px',
borderBottom: '1px solid $thBorderColor',
}}
alignment="center"
distribution="start"
>
<StyledText
css={{
fontWeight: '600',
fontSize: '15px',
fontFamily: '$display',
width: '100%',
color: '$thTextContrast2',
}}
>
HIGHLIGHTS
</StyledText>
<Dropdown
triggerElement={
<Box
css={{
display: 'flex',
height: '20px',
width: '20px',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '1000px',
'&:hover': {
bg: '#898989',
},
}}
>
<DotsThreeVertical size={20} color="#EBEBEB" weight="bold" />
</Box>
}
>
<DropdownOption
onSelect={() => {
exportHighlights()
}}
title="Export"
/>
</Dropdown>
</HStack>
<VStack css={{ height: '100%', width: '100%' }} distribution="start">
{(props.item.node.highlights ?? []).map((highlight) => (
<HighlightItem
key={highlight.id}
viewer={props.viewer}
item={props.item.node}
highlight={highlight}
/>
))}
</VStack>
</VStack>
</HStack>
)
}
type HighlightCountChipProps = {
count: number
selected: boolean
}
function HighlightCountChip(props: HighlightCountChipProps): JSX.Element {
return (
<HStack
css={{
gap: '5px',
borderRadius: '12px',
marginLeft: 'auto',
minWidth: '50px',
minHeight: '25px',
width: '47px',
color: props.selected ? '#3D3D3D' : '#898989',
bg: props.selected ? '#D9D9D9' : '$thBackground2',
fontFamily: '$inter',
fontSize: '14px',
fontWeight: '500',
ml: '30px',
}}
alignment="center"
distribution="center"
>
{props.count}
<HighlighterCircle
size={15}
color={props.selected ? '#3D3D3D' : '#898989'}
/>
</HStack>
)
}

View file

@ -6,34 +6,23 @@ import type {
LibraryItemsQueryInput,
} from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { useGetLibraryItemsQuery } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery'
import {
useGetViewerQuery,
UserBasicData,
} from '../../../lib/networking/queries/useGetViewerQuery'
import { LinkedItemCardAction } from '../../patterns/LibraryCards/CardTypes'
import { LinkedItemCard } from '../../patterns/LibraryCards/LinkedItemCard'
import { useRouter } from 'next/router'
import { Button } from '../../elements/Button'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LibrarySearchBar } from './LibrarySearchBar'
import { StyledText } from '../../elements/StyledText'
import { AddLinkModal } from './AddLinkModal'
import { styled, theme } from '../../tokens/stitches.config'
import { ListLayoutIcon } from '../../elements/images/ListLayoutIcon'
import { GridLayoutIcon } from '../../elements/images/GridLayoutIcon'
import {
libraryListCommands,
searchBarCommands,
} from '../../../lib/keyboardShortcuts/navigationShortcuts'
import { libraryListCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts'
import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
import { ShareArticleModal } from '../article/ShareArticleModal'
import { webBaseURL } from '../../../lib/appConfig'
import { Toaster } from 'react-hot-toast'
import { SnoozeLinkModal } from '../article/SnoozeLinkModal'
import {
createReminderMutation,
ReminderType,
} from '../../../lib/networking/mutations/createReminderMutation'
import { useFetchMore } from '../../../lib/hooks/useFetchMoreScroll'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { ConfirmationModal } from '../../patterns/ConfirmationModal'
import { SetLabelsModal } from '../article/SetLabelsModal'
import { Label } from '../../../lib/networking/fragments/labelFragment'
@ -44,7 +33,7 @@ import {
State,
} from '../../../lib/networking/fragments/articleFragment'
import { Action, createAction, useKBar, useRegisterActions } from 'kbar'
import { EditTitleModal } from './EditTitleModal'
import { EditLibraryItemModal } from './EditItemModals'
import { useGetUserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
import debounce from 'lodash/debounce'
import {
@ -55,22 +44,12 @@ import {
import axios from 'axios'
import { uploadFileRequestMutation } from '../../../lib/networking/mutations/uploadFileMutation'
import { setLabelsMutation } from '../../../lib/networking/mutations/setLabelsMutation'
import { LibraryHeader } from './LibraryHeader'
import { LibraryFilterMenu } from './LibraryFilterMenu'
import { HighlightItemsLayout } from './HighlightsLayout'
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
const timeZoneHourDiff = -new Date().getTimezoneOffset() / 60
const SAVED_SEARCHES: Record<string, string> = {
Inbox: `in:inbox`,
'Read Later': `in:inbox -label:Newsletter`,
Highlights: `type:highlights`,
Today: `in:inbox saved:${
new Date(new Date().getTime() - 24 * 3600000).toISOString().split('T')[0]
}Z${timeZoneHourDiff.toLocaleString('en-US', {
signDisplay: 'always',
})}..*`,
Newsletters: `in:inbox label:Newsletter`,
}
export type LibraryMode = 'reads' | 'highlights'
const fetchSearchResults = async (query: string, cb: any) => {
if (!query.startsWith('#')) return
@ -92,6 +71,7 @@ export function HomeFeedContainer(): JSX.Element {
const router = useRouter()
const { queryValue } = useKBar((state) => ({ queryValue: state.searchQuery }))
const [searchResults, setSearchResults] = useState<SearchItem[]>([])
const [mode, setMode] = useState<LibraryMode>('reads')
const defaultQuery = {
limit: 10,
@ -101,14 +81,6 @@ export function HomeFeedContainer(): JSX.Element {
const gridContainerRef = useRef<HTMLDivElement>(null)
const [shareTarget, setShareTarget] = useState<LibraryItem | undefined>(
undefined
)
const [snoozeTarget, setSnoozeTarget] = useState<LibraryItem | undefined>(
undefined
)
const [labelsTarget, setLabelsTarget] = useState<LibraryItem | undefined>(
undefined
)
@ -122,14 +94,6 @@ export function HomeFeedContainer(): JSX.Element {
const [queryInputs, setQueryInputs] =
useState<LibraryItemsQueryInput>(defaultQuery)
useKeyboardShortcuts(
searchBarCommands((action) => {
if (action === 'clearSearch') {
setQueryInputs(defaultQuery)
}
})
)
const {
itemsPages,
size,
@ -150,6 +114,17 @@ export function HomeFeedContainer(): JSX.Element {
} else setSearchResults([])
}, [queryValue])
useEffect(() => {
if (
queryInputs.searchQuery &&
queryInputs.searchQuery?.indexOf('mode:highlights') > -1
) {
setMode('highlights')
} else {
setMode('reads')
}
}, [queryInputs])
useEffect(() => {
if (!router.isReady) return
const q = router.query['q']
@ -161,9 +136,17 @@ export function HomeFeedContainer(): JSX.Element {
setQueryInputs({ ...queryInputs, searchQuery: qs })
performActionOnItem('refresh', undefined as unknown as any)
}
const mode = router.query['mode']
// intentionally not watching queryInputs here to prevent infinite looping
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setQueryInputs, router.isReady, router.query, performActionOnItem])
}, [
setMode,
setQueryInputs,
router.isReady,
router.query,
performActionOnItem,
])
const hasMore = useMemo(() => {
if (!itemsPages) {
@ -339,12 +322,6 @@ export function HomeFeedContainer(): JSX.Element {
case 'mark-unread':
performActionOnItem('mark-unread', item)
break
case 'share':
setShareTarget(item)
break
case 'snooze':
setSnoozeTarget(item)
break
case 'set-labels':
setLabelsTarget(item)
break
@ -357,22 +334,8 @@ export function HomeFeedContainer(): JSX.Element {
}
const modalTargetItem = useMemo(() => {
return (
labelsTarget ||
snoozeTarget ||
shareTarget ||
linkToEdit ||
linkToRemove ||
linkToUnsubscribe
)
}, [
labelsTarget,
snoozeTarget,
shareTarget,
linkToEdit,
linkToRemove,
linkToUnsubscribe,
])
return labelsTarget || linkToEdit || linkToRemove || linkToUnsubscribe
}, [labelsTarget, linkToEdit, linkToRemove, linkToUnsubscribe])
useKeyboardShortcuts(
libraryListCommands((action) => {
@ -472,9 +435,6 @@ export function HomeFeedContainer(): JSX.Element {
case 'showEditLabelsModal':
handleCardAction('set-labels', activeItem)
break
case 'shareItem':
setShareTarget(activeItem)
break
case 'sortDescending':
setQueryInputs({ ...queryInputs, sortDescending: true })
break
@ -572,6 +532,8 @@ export function HomeFeedContainer(): JSX.Element {
reloadItems={mutate}
searchTerm={queryInputs.searchQuery}
gridContainerRef={gridContainerRef}
mode={mode}
setMode={setMode}
applySearchQuery={(searchQuery: string) => {
setQueryInputs({
...queryInputs,
@ -583,8 +545,10 @@ export function HomeFeedContainer(): JSX.Element {
} else {
qp.delete('q')
}
const href = `${window.location.pathname}?${qp.toString()}`
router.push(href, href, { shallow: true })
window.sessionStorage.setItem('q', qp.toString())
performActionOnItem('refresh', undefined as unknown as any)
}}
loadMore={() => {
@ -597,10 +561,6 @@ export function HomeFeedContainer(): JSX.Element {
hasData={!!itemsPages}
totalItems={itemsPages?.[0].search.pageInfo.totalCount || 0}
isValidating={isValidating}
shareTarget={shareTarget}
setShareTarget={setShareTarget}
snoozeTarget={snoozeTarget}
setSnoozeTarget={setSnoozeTarget}
labelsTarget={labelsTarget}
setLabelsTarget={setLabelsTarget}
showAddLinkModal={showAddLinkModal}
@ -631,10 +591,6 @@ type HomeFeedContentProps = {
totalItems: number
isValidating: boolean
loadMore: () => void
shareTarget: LibraryItem | undefined
setShareTarget: (target: LibraryItem | undefined) => void
snoozeTarget: LibraryItem | undefined
setSnoozeTarget: (target: LibraryItem | undefined) => void
labelsTarget: LibraryItem | undefined
setLabelsTarget: (target: LibraryItem | undefined) => void
showAddLinkModal: boolean
@ -650,22 +606,48 @@ type HomeFeedContentProps = {
linkToUnsubscribe: LibraryItem | undefined
setLinkToUnsubscribe: (set: LibraryItem | undefined) => void
mode: LibraryMode
setMode: (set: LibraryMode) => void
actionHandler: (
action: LinkedItemCardAction,
item: LibraryItem | undefined
) => Promise<void>
}
const DragnDropContainer = styled('div', {
width: '100%',
height: '80%',
position: 'absolute',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: '1',
alignSelf: 'center',
left: 0,
})
const DragnDropStyle = styled('div', {
border: '3px dashed gray',
backgroundColor: 'aliceblue',
borderRadius: '5px',
width: '100%',
height: '100%',
opacity: '0.9',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'center',
left: 0,
margin: '16px',
})
function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
const { viewerData } = useGetViewerQuery()
const [layout, setLayout] = usePersistedState<LayoutType>({
key: 'libraryLayout',
initialValue: 'GRID_LAYOUT',
})
const [showRemoveLinkConfirmation, setShowRemoveLinkConfirmation] =
useState(false)
const [showUnsubscribeConfirmation, setShowUnsubscribeConfirmation] =
useState(false)
const updateLayout = useCallback(
async (newLayout: LayoutType) => {
@ -675,54 +657,74 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
[layout, setLayout]
)
const [showFilterMenu, setShowFilterMenu] = useState(false)
return (
<VStack
css={{
height: '100%',
width: props.mode == 'highlights' ? '100%' : 'unset',
}}
>
<LibraryHeader
layout={layout}
updateLayout={updateLayout}
searchTerm={props.searchTerm}
applySearchQuery={(searchQuery: string) => {
console.log('searching with searchQuery: ', searchQuery)
props.applySearchQuery(searchQuery)
}}
showFilterMenu={showFilterMenu}
setShowFilterMenu={setShowFilterMenu}
/>
<HStack css={{ width: '100%', height: '100%' }}>
<LibraryFilterMenu
setShowAddLinkModal={props.setShowAddLinkModal}
searchTerm={props.searchTerm}
applySearchQuery={(searchQuery: string) => {
console.log('searching with searchQuery: ', searchQuery)
props.applySearchQuery(searchQuery)
}}
showFilterMenu={showFilterMenu}
setShowFilterMenu={setShowFilterMenu}
/>
{props.mode == 'highlights' && (
<HighlightItemsLayout
gridContainerRef={props.gridContainerRef}
items={props.items}
viewer={viewerData?.me}
/>
)}
{props.mode == 'reads' && (
<LibraryItemsLayout
viewer={viewerData?.me}
layout={layout}
{...props}
/>
)}
</HStack>
</VStack>
)
}
type LibraryItemsLayoutProps = {
layout: LayoutType
viewer?: UserBasicData
} & HomeFeedContentProps
function LibraryItemsLayout(props: LibraryItemsLayoutProps): JSX.Element {
const [uploadingFiles, setUploadingFiles] = useState([])
const [inDragOperation, setInDragOperation] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [showRemoveLinkConfirmation, setShowRemoveLinkConfirmation] =
useState(false)
const [showUnsubscribeConfirmation, setShowUnsubscribeConfirmation] =
useState(false)
const [, updateState] = useState({})
const StyledToggleButton = styled('button', {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: '0px',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
width: '32px',
height: '32px',
borderRadius: '4px',
'&:hover': {
opacity: 0.8,
},
'&[data-state="on"]': {
bg: 'rgb(43, 43, 43)',
},
})
const DragnDropContainer = styled('div', {
width: '100%',
height: '80%',
position: 'absolute',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: '1',
alignSelf: 'center',
left: 0,
})
const DragnDropStyle = styled('div', {
border: '3px dashed gray',
backgroundColor: 'aliceblue',
borderRadius: '5px',
width: '100%',
height: '100%',
opacity: '0.9',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'center',
left: 0,
margin: '16px',
})
const removeItem = () => {
if (!props.linkToRemove) {
return
@ -742,10 +744,6 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
setShowUnsubscribeConfirmation(false)
}
const [uploadingFiles, setUploadingFiles] = useState([])
const [inDragOperation, setInDragOperation] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const handleDrop = async (acceptedFiles: any) => {
setInDragOperation(false)
setUploadingFiles(acceptedFiles.map((file: { name: any }) => file.name))
@ -795,116 +793,16 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
return (
<>
<VStack
alignment="center"
alignment="start"
distribution="start"
css={{
px: '$3',
width: '100%',
'@smDown': {
px: '$2',
},
height: '100%',
}}
>
<Toaster />
{props.isValidating && props.items.length == 0 && <TopBarProgress />}
<HStack alignment="center" distribution="start" css={{ width: '100%' }}>
<StyledText
style="subHeadline"
css={{
mr: '32px',
'@smDown': {
mr: '16px',
},
}}
>
Library
</StyledText>
<Box
css={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<StyledToggleButton
data-state={layout === 'GRID_LAYOUT' ? 'on' : 'off'}
onClick={() => {
updateLayout('GRID_LAYOUT')
}}
>
<GridLayoutIcon color={'rgb(211, 211, 213)'} />
</StyledToggleButton>
<StyledToggleButton
data-state={layout === 'LIST_LAYOUT' ? 'on' : 'off'}
onClick={() => {
updateLayout('LIST_LAYOUT')
}}
>
<ListLayoutIcon color={'rgb(211, 211, 213)'} />
</StyledToggleButton>
</Box>
<Button
style="ctaDarkYellow"
css={{ marginLeft: 'auto' }}
onClick={() => {
props.setShowAddLinkModal(true)
}}
>
Add Link
</Button>
</HStack>
<LibrarySearchBar
searchTerm={props.searchTerm}
applySearchQuery={props.applySearchQuery}
/>
{viewerData?.me && (
<Box
css={{
display: 'flex',
width: '100%',
height: '44px',
marginTop: '16px',
gap: '8px',
flexDirection: 'row',
overflowY: 'scroll',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': {
display: 'none',
},
}}
>
{Object.keys(SAVED_SEARCHES).map((key) => {
const isInboxTerm = (term: string) => {
return !term || term === 'in:inbox'
}
const searchQuery = SAVED_SEARCHES[key]
const style =
searchQuery === props.searchTerm ||
(!props.searchTerm && isInboxTerm(searchQuery))
? 'ctaDarkYellow'
: 'ctaLightGray'
return (
<Button
key={key}
style={style}
onClick={() => {
props.applySearchQuery(searchQuery)
}}
css={{
p: '10px 12px',
height: '37.5px',
borderRadius: '6px',
whiteSpace: 'nowrap',
}}
>
{key}
</Button>
)
})}
</Box>
)}
<Dropzone
onDrop={handleDrop}
onDragEnter={() => {
@ -922,7 +820,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
{({ getRootProps, getInputProps, acceptedFiles, fileRejections }) => (
<div
{...getRootProps({ className: 'dropzone' })}
style={{ width: '100%', height: '100%' }}
style={{ height: '100%', width: '100%' }}
>
{inDragOperation && uploadingFiles.length < 1 && (
<DragnDropContainer>
@ -981,85 +879,21 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
}}
/>
) : (
<Box
ref={props.gridContainerRef}
css={{
py: '$3',
display: 'grid',
width: '100%',
gridAutoRows: 'auto',
borderRadius: '8px',
gridGap: layout == 'LIST_LAYOUT' ? '0' : '$3',
marginTop: layout == 'LIST_LAYOUT' ? '21px' : '0',
marginBottom: '0px',
paddingTop: layout == 'LIST_LAYOUT' ? '0' : '21px',
paddingBottom: layout == 'LIST_LAYOUT' ? '0px' : '21px',
overflow: 'hidden',
'@smDown': {
border: 'unset',
width: layout == 'LIST_LAYOUT' ? '100vw' : undefined,
margin:
layout == 'LIST_LAYOUT' ? '16px -16px' : undefined,
borderRadius: layout == 'LIST_LAYOUT' ? 0 : undefined,
},
'@md': {
gridTemplateColumns:
layout == 'LIST_LAYOUT' ? 'none' : '1fr 1fr',
},
'@lg': {
gridTemplateColumns:
layout == 'LIST_LAYOUT' ? 'none' : 'repeat(3, 1fr)',
},
}}
>
{props.items.map((linkedItem) => (
<Box
className="linkedItemCard"
data-testid="linkedItemCard"
id={linkedItem.node.id}
tabIndex={0}
key={linkedItem.node.id}
css={{
width: '100%',
'&> div': {
bg: '$grayBg',
},
'&:focus': {
'> div': {
bg: '$grayBgActive',
},
},
'&:hover': {
'> div': {
bg: '$grayBgActive',
},
},
}}
>
{viewerData?.me && (
<LinkedItemCard
layout={layout}
item={linkedItem.node}
viewer={viewerData.me}
handleAction={(action: LinkedItemCardAction) => {
if (action === 'delete') {
setShowRemoveLinkConfirmation(true)
props.setLinkToRemove(linkedItem)
} else if (action === 'editTitle') {
props.setShowEditTitleModal(true)
props.setLinkToEdit(linkedItem)
} else if (action == 'unsubscribe') {
setShowUnsubscribeConfirmation(true)
props.setLinkToUnsubscribe(linkedItem)
} else {
props.actionHandler(action, linkedItem)
}
}}
/>
)}
</Box>
))}
</Box>
<LibraryItems
items={props.items}
layout={props.layout}
viewer={props.viewer}
gridContainerRef={props.gridContainerRef}
setShowEditTitleModal={props.setShowEditTitleModal}
setLinkToEdit={props.setLinkToEdit}
setShowUnsubscribeConfirmation={
setShowUnsubscribeConfirmation
}
setLinkToRemove={props.setLinkToRemove}
setLinkToUnsubscribe={props.setLinkToUnsubscribe}
setShowRemoveLinkConfirmation={setShowRemoveLinkConfirmation}
actionHandler={props.actionHandler}
/>
)}
<HStack
distribution="center"
@ -1084,21 +918,12 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
)}
</Dropzone>
</VStack>
{/* Temporary code */}
{/* <div>
<strong>Files:</strong>
<ul>
{uploadingFiles.map((fileName) => (
<li key={fileName}>{fileName}</li>
))}
</ul>
</div> */}
{/* Temporary code */}
{props.showAddLinkModal && (
<AddLinkModal onOpenChange={() => props.setShowAddLinkModal(false)} />
)}
{props.showEditTitleModal && (
<EditTitleModal
<EditLibraryItemModal
updateItem={(item: LibraryItem) =>
props.actionHandler('update-item', item)
}
@ -1106,62 +931,6 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
item={props.linkToEdit as LibraryItem}
/>
)}
{props.shareTarget && viewerData?.me?.profile.username && (
<ShareArticleModal
url={`${webBaseURL}${viewerData?.me?.profile.username}/${props.shareTarget.node.slug}/highlights?r=true`}
title={props.shareTarget.node.title}
imageURL={props.shareTarget.node.image}
author={props.shareTarget.node.author}
publishedAt={
props.shareTarget.node.publishedAt ??
props.shareTarget.node.createdAt
}
description={props.shareTarget.node.description}
originalArticleUrl={props.shareTarget.node.originalArticleUrl}
onOpenChange={() => {
if (props.shareTarget) {
const item = document.getElementById(props.shareTarget.node.id)
if (item) {
item.focus()
}
props.setShareTarget(undefined)
}
}}
/>
)}
{props.snoozeTarget && (
<SnoozeLinkModal
submit={(option: string, sendReminder: boolean, msg: string) => {
if (!props.snoozeTarget) return
createReminderMutation(
props.snoozeTarget?.node.id,
ReminderType.Tonight,
true,
sendReminder
)
.then(() => {
return props.actionHandler('archive', props.snoozeTarget)
})
.then(() => {
showSuccessToast(msg, { position: 'bottom-right' })
})
.catch((error) => {
showErrorToast('There was an error snoozing your link.', {
position: 'bottom-right',
})
})
}}
onOpenChange={() => {
if (props.snoozeTarget) {
const item = document.getElementById(props.snoozeTarget.node.id)
if (item) {
item.focus()
}
props.setSnoozeTarget(undefined)
}
}}
/>
)}
{showRemoveLinkConfirmation && (
<ConfirmationModal
richMessage={
@ -1170,7 +939,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
Are you sure you want to delete this item? All associated notes
and highlights will be deleted.
</StyledText>
{props.linkToRemove?.node && viewerData?.me && (
{props.linkToRemove?.node && props.viewer && (
<Box
css={{
transform: 'scale(0.6)',
@ -1181,7 +950,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
>
<LinkedItemCard
item={props.linkToRemove?.node}
viewer={viewerData.me}
viewer={props.viewer}
layout="GRID_LAYOUT"
// eslint-disable-next-line @typescript-eslint/no-empty-function
handleAction={() => {}}
@ -1232,3 +1001,121 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
</>
)
}
type LibraryItemsProps = {
items: LibraryItem[]
layout: LayoutType
viewer: UserBasicData | undefined
gridContainerRef: React.RefObject<HTMLDivElement>
setShowEditTitleModal: (show: boolean) => void
setLinkToEdit: (set: LibraryItem | undefined) => void
setShowUnsubscribeConfirmation: (show: true) => void
setLinkToRemove: (set: LibraryItem | undefined) => void
setLinkToUnsubscribe: (set: LibraryItem | undefined) => void
setShowRemoveLinkConfirmation: (show: true) => void
actionHandler: (
action: LinkedItemCardAction,
item: LibraryItem | undefined
) => Promise<void>
}
function LibraryItems(props: LibraryItemsProps): JSX.Element {
return (
<Box
ref={props.gridContainerRef}
css={{
py: '$3',
display: 'grid',
width: '100%',
gridAutoRows: 'auto',
borderRadius: '8px',
gridGap: props.layout == 'LIST_LAYOUT' ? '0' : '20px',
marginTop: props.layout == 'LIST_LAYOUT' ? '21px' : '0',
marginBottom: '0px',
paddingTop: props.layout == 'LIST_LAYOUT' ? '0' : '21px',
paddingBottom: props.layout == 'LIST_LAYOUT' ? '0px' : '21px',
overflow: 'hidden',
'@xlgDown': {
border: 'unset',
borderRadius: props.layout == 'LIST_LAYOUT' ? 0 : undefined,
},
'@smDown': {
border: 'unset',
width: props.layout == 'LIST_LAYOUT' ? '100vw' : undefined,
margin: props.layout == 'LIST_LAYOUT' ? '16px -16px' : undefined,
borderRadius: props.layout == 'LIST_LAYOUT' ? 0 : undefined,
},
'@md': {
gridTemplateColumns:
props.layout == 'LIST_LAYOUT' ? 'none' : '1fr 1fr',
},
'@lg': {
gridTemplateColumns:
props.layout == 'LIST_LAYOUT' ? 'none' : 'repeat(2, 1fr)',
},
'@xl': {
gridTemplateColumns:
props.layout == 'LIST_LAYOUT' ? 'none' : 'repeat(3, 1fr)',
},
'@xxl': {
gridTemplateColumns:
props.layout == 'LIST_LAYOUT' ? 'none' : 'repeat(4, 1fr)',
},
}}
>
{props.items.map((linkedItem) => (
<Box
className="linkedItemCard"
data-testid="linkedItemCard"
id={linkedItem.node.id}
tabIndex={0}
key={linkedItem.node.id}
css={{
width: '100%',
'&> div': {
bg: '$thBackground3',
},
'&:focus': {
'> div': {
bg: '$thBackgroundActive',
},
},
'&:hover': {
'> div': {
bg: '$thBackgroundActive',
},
'> a': {
bg: '$thBackgroundActive',
},
},
}}
>
{props.viewer && (
<LinkedItemCard
layout={props.layout}
item={linkedItem.node}
viewer={props.viewer}
handleAction={(action: LinkedItemCardAction) => {
if (action === 'delete') {
props.setShowRemoveLinkConfirmation(true)
props.setLinkToRemove(linkedItem)
} else if (action === 'editTitle') {
props.setShowEditTitleModal(true)
props.setLinkToEdit(linkedItem)
} else if (action == 'unsubscribe') {
props.setShowUnsubscribeConfirmation(true)
props.setLinkToUnsubscribe(linkedItem)
} else {
props.actionHandler(action, linkedItem)
}
}}
/>
)}
</Box>
))}
</Box>
)
}

View file

@ -0,0 +1,494 @@
import { ReactNode, useMemo, useState } from 'react'
import { StyledText } from '../../elements/StyledText'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
import { Button } from '../../elements/Button'
import { CaretRight, Circle, DotsThree, Plus } from 'phosphor-react'
import { useGetSubscriptionsQuery } from '../../../lib/networking/queries/useGetSubscriptionsQuery'
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { theme } from '../../tokens/stitches.config'
import { currentThemeName } from '../../../lib/themeUpdater'
import { MOBILE_HEADER_HEIGHT } from './HeaderSpacer'
import { useRegisterActions } from 'kbar'
export const LIBRARY_LEFT_MENU_WIDTH = '300px'
type LibraryFilterMenuProps = {
setShowAddLinkModal: (show: boolean) => void
searchTerm: string | undefined
applySearchQuery: (searchTerm: string) => void
showFilterMenu: boolean
setShowFilterMenu: (show: boolean) => void
}
export function LibraryFilterMenu(props: LibraryFilterMenuProps): JSX.Element {
return (
<>
<Box
css={{
left: '0px',
top: '105px',
position: 'fixed',
bg: '$thBackground',
height: '100%',
width: LIBRARY_LEFT_MENU_WIDTH,
borderRight: '1px solid $thBorderColor',
overflowY: 'auto',
overflowX: 'hidden',
'&::-webkit-scrollbar': {
display: 'none',
},
'@mdDown': {
visibility: props.showFilterMenu ? 'visible' : 'hidden',
top: props.showFilterMenu ? MOBILE_HEADER_HEIGHT : '100%',
width: '100%',
transition: 'visibility 0s, top 150ms',
},
}}
>
<SavedSearches {...props} />
<Subscriptions {...props} />
<Labels {...props} />
<AddLinkButton
showAddLinkModal={() => props.setShowAddLinkModal(true)}
/>
</Box>
{/* This spacer pushes library content to the right of
the fixed left side menu. */}
<Box
css={{
minWidth: LIBRARY_LEFT_MENU_WIDTH,
height: '100%',
bg: '$thBackground',
'@mdDown': {
display: 'none',
},
}}
></Box>
</>
)
}
function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
const items = [
{
name: 'Inbox',
term: 'in:inbox',
},
{
name: 'Read Later',
term: 'in:inbox -label:Newsletter',
},
{
name: 'Highlights',
term: 'has:highlights mode:highlights',
},
{
name: 'Unlabeled',
term: 'no:label',
},
{
name: 'Files',
term: 'type:file',
},
{
name: 'Archived',
term: 'in:archive',
},
]
useRegisterActions(
items.map((item, idx) => {
const key = String(idx + 1)
return {
id: `saved_search_${key}`,
name: item.name,
shortcut: [key],
section: 'Saved Searches',
keywords: '?' + item.name,
perform: () => {
props.applySearchQuery(item.term)
},
}
}),
[]
)
return (
<MenuPanel title="Saved Searches">
{items.map((item) => (
<FilterButton
key={item.name}
text={item.name}
filterTerm={item.term}
{...props}
/>
))}
<Box css={{ height: '10px' }}></Box>
</MenuPanel>
)
}
function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
const { subscriptions } = useGetSubscriptionsQuery()
const [viewAll, setViewAll] = useState(false)
useRegisterActions(
subscriptions.map((subscription, idx) => {
const key = String(idx + 1)
const name = subscription.name
return {
id: `subscription_${key}`,
section: 'Subscriptions',
name: name,
keywords: '*' + name,
perform: () => {
props.applySearchQuery(`subscription:\"${name}\"`)
},
}
}),
[subscriptions]
)
return (
<MenuPanel
title="Subscriptions"
editTitle="Edit Subscriptions"
editFunc={() => {
window.location.href = '/settings/subscriptions'
}}
>
{subscriptions.slice(0, viewAll ? undefined : 4).map((item) => {
return (
<FilterButton
key={item.id}
filterTerm={`subscription:\"${item.name}\"`}
text={item.name}
{...props}
/>
)
})}
<ViewAllButton state={viewAll} setState={setViewAll} />
</MenuPanel>
)
}
function Labels(props: LibraryFilterMenuProps): JSX.Element {
const { labels } = useGetLabelsQuery()
const [viewAll, setViewAll] = useState(false)
return (
<MenuPanel
title="Labels"
editTitle="Edit Labels"
editFunc={() => {
window.location.href = '/settings/labels'
}}
>
{labels.slice(0, viewAll ? undefined : 4).map((item) => {
return <LabelButton key={item.id} label={item} {...props} />
})}
<ViewAllButton state={viewAll} setState={setViewAll} />
</MenuPanel>
)
}
type MenuPanelProps = {
title: string
children: ReactNode
editFunc?: () => void
editTitle?: string
}
function MenuPanel(props: MenuPanelProps): JSX.Element {
return (
<VStack
css={{
m: '0px',
width: '100%',
borderBottom: '1px solid $thBorderColor',
px: '15px',
}}
alignment="start"
distribution="start"
>
<HStack css={{ width: '100%' }} distribution="start" alignment="start">
<StyledText
css={{
fontFamily: 'Inter',
fontWeight: '600',
fontSize: '16px',
lineHeight: '125%',
color: '$thTextContrast',
pl: '10px',
my: '20px',
}}
>
{props.title}
</StyledText>
<SpanBox
css={{
my: '15px',
marginLeft: 'auto',
height: '100%',
verticalAlign: 'middle',
}}
>
{props.editTitle && props.editFunc && (
<Dropdown
triggerElement={
<Box
css={{
display: 'flex',
height: '30px',
width: '30px',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '1000px',
'&:hover': {
bg: '$thBackground4',
},
}}
>
<DotsThree
size={25}
weight="bold"
color={theme.colors.thTextSubtle2.toString()}
/>
</Box>
}
>
<DropdownOption
title={props.editTitle}
onSelect={() => {
if (props.editFunc) {
props.editFunc()
}
}}
/>
</Dropdown>
)}
</SpanBox>
</HStack>
{props.children}
</VStack>
)
}
type FilterButtonProps = {
text: string
filterTerm: string
searchTerm: string | undefined
applySearchQuery: (searchTerm: string) => void
setShowFilterMenu: (show: boolean) => void
}
function FilterButton(props: FilterButtonProps): JSX.Element {
const isInboxFilter = (filter: string) => {
return filter === '' || filter === 'in:inbox'
}
const selected = useMemo(() => {
if (isInboxFilter(props.filterTerm) && !props.searchTerm) {
return true
}
return props.searchTerm === props.filterTerm
}, [props.searchTerm, props.filterTerm])
return (
<Box
css={{
pl: '10px',
mb: '2px',
display: 'flex',
width: '100%',
maxWidth: '100%',
height: '32px',
backgroundColor: selected ? '#FFEA9F' : 'unset',
fontSize: '14px',
fontWeight: 'regular',
fontFamily: '$display',
color: selected ? '#3D3D3D' : '$thTextSubtle',
verticalAlign: 'middle',
borderRadius: '3px',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
alignItems: 'center',
'&:hover': {
backgroundColor: selected ? '#FFEA9F' : '$thBackground4',
},
'&:active': {
backgroundColor: selected ? '#FFEA9F' : '$thBackground4',
},
}}
onClick={(e) => {
props.applySearchQuery(props.filterTerm)
props.setShowFilterMenu(false)
e.preventDefault()
}}
>
{props.text}
</Box>
)
}
type LabelButtonProps = {
label: Label
searchTerm: string | undefined
applySearchQuery: (searchTerm: string) => void
}
function LabelButton(props: LabelButtonProps): JSX.Element {
const labelId = `checkbox-label-${props.label.id}`
const state = useMemo(() => {
const term = props.searchTerm ?? ''
if (term.indexOf(`label:\"${props.label.name}\"`) >= 0) {
return 'on'
}
return 'off'
}, [props.searchTerm, props.label])
return (
<HStack
css={{
pl: '10px',
pt: '2px', // TODO: hack to middle align
width: '100%',
height: '30px',
fontSize: '16px',
fontWeight: 'regular',
color: '$thTextSubtle',
verticalAlign: 'middle',
borderRadius: '3px',
m: '0px',
'&:hover': {
backgroundColor: '$thBackground4',
},
}}
alignment="center"
distribution="start"
>
<label htmlFor={labelId} style={{ width: '100%' }}>
<Circle size={9} color={props.label.color} weight="fill" />
<SpanBox css={{ pl: '10px' }}>{props.label.name}</SpanBox>
</label>
<SpanBox
css={{
ml: 'auto',
}}
>
<input
id={labelId}
type="checkbox"
checked={state === 'on'}
onChange={(e) => {
if (e.target.checked) {
props.applySearchQuery(
`${props.searchTerm ?? ''} label:\"${props.label.name}\"`
)
} else {
const query =
props.searchTerm?.replace(
`label:\"${props.label.name}\"`,
''
) ?? ''
props.applySearchQuery(query)
}
}}
/>
</SpanBox>
</HStack>
)
}
type AddLinkButtonProps = {
showAddLinkModal: () => void
}
function AddLinkButton(props: AddLinkButtonProps): JSX.Element {
const currentTheme = currentThemeName()
const isDark = currentTheme == 'Dark'
return (
<>
<VStack
css={{
marginTop: 'auto',
width: LIBRARY_LEFT_MENU_WIDTH,
height: '80px',
pl: '25px',
}}
distribution="center"
>
<Button
css={{
height: '40px',
p: '15px',
pr: '20px',
fontSize: '14px',
verticalAlign: 'center',
color: isDark
? theme.colors.thHighContrast.toString()
: theme.colors.thTextContrast2.toString(),
display: 'flex',
alignItems: 'center',
fontWeight: '600',
bg: isDark ? 'transparent' : theme.colors.thBackground2.toString(),
border: `1px solid ${
isDark ? theme.colors.thHighContrast.toString() : 'transparent'
}`,
}}
onClick={(e) => {
props.showAddLinkModal()
e.preventDefault()
}}
>
<Plus size={16} weight="bold" />
<SpanBox css={{ width: '10px' }}></SpanBox>Add Link
</Button>
</VStack>
<Box css={{ height: '180px ' }} />
</>
)
}
type ViewAllButtonProps = {
state: boolean
setState: (state: boolean) => void
}
function ViewAllButton(props: ViewAllButtonProps): JSX.Element {
return (
<Button
style="ghost"
css={{
display: 'flex',
pl: '10px',
color: '#898989',
fontWeight: '600',
fontSize: '12px',
py: '20px',
gap: '2px',
alignItems: 'center',
}}
onClick={(e) => {
props.setState(!props.state)
e.preventDefault()
}}
>
{props.state ? 'Hide' : 'View All'}
{props.state ? null : (
<CaretRight size={12} color="#898989" weight="bold" />
)}
</Button>
)
}

View file

@ -0,0 +1,414 @@
import { useRef, useState } from 'react'
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
import { theme } from '../../tokens/stitches.config'
import { FormInput } from '../../elements/FormElements'
import { searchBarCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts'
import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
import { Button, IconButton } from '../../elements/Button'
import { FunnelSimple, MagnifyingGlass, X } from 'phosphor-react'
import { ListSelectorIcon } from '../../elements/images/ListSelectorIcon'
import { GridSelectorIcon } from '../../elements/images/GridSelectorIcon'
import { LayoutType } from './HomeFeedContainer'
import { PrimaryDropdown } from '../PrimaryDropdown'
import { LogoBox } from '../../elements/LogoBox'
import { OmnivoreSmallLogo } from '../../elements/images/OmnivoreNameLogo'
import {
HeaderSpacer,
HEADER_HEIGHT,
MOBILE_HEADER_HEIGHT,
} from './HeaderSpacer'
type LibraryHeaderProps = {
layout: LayoutType
updateLayout: (layout: LayoutType) => void
searchTerm: string | undefined
applySearchQuery: (searchQuery: string) => void
showFilterMenu: boolean
setShowFilterMenu: (show: boolean) => void
}
export function LibraryHeader(props: LibraryHeaderProps): JSX.Element {
return (
<>
<VStack
alignment="center"
distribution="start"
css={{
top: '0',
left: '0',
zIndex: 5,
position: 'fixed',
width: '100%',
height: HEADER_HEIGHT,
bg: '$thBackground',
pt: '35px',
borderBottom: '1px solid $thBorderColor',
'@mdDown': {
height: MOBILE_HEADER_HEIGHT,
pt: '0px',
},
}}
>
{/* These will display/hide depending on breakpoints */}
<LargeHeaderLayout {...props} />
<SmallHeaderLayout {...props} />
</VStack>
{/* This spacer is put in to push library content down
below the fixed header height. */}
<HeaderSpacer />
</>
)
}
function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element {
return (
<HStack
alignment="center"
distribution="start"
css={{
width: '100%',
height: '100%',
'@mdDown': {
display: 'none',
},
}}
>
<LogoBox />
<SearchBox {...props} />
<ControlButtonBox
layout={props.layout}
updateLayout={props.updateLayout}
/>
</HStack>
)
}
function SmallHeaderLayout(props: LibraryHeaderProps): JSX.Element {
const [showInlineSearch, setShowInlineSearch] = useState(false)
return (
<HStack
alignment="center"
distribution="start"
css={{
width: '100%',
height: '100%',
bg: '$thBackground3',
'@md': {
display: 'none',
},
}}
>
{showInlineSearch ? (
<HStack css={{ pl: '10px', pr: '0px', width: '100%' }}>
<SearchBox {...props} compact={true} />
<Button
style="cancelGeneric"
onClick={(event) => {
setShowInlineSearch(false)
event.preventDefault()
}}
>
Close
</Button>
</HStack>
) : (
<>
<MenuHeaderButton {...props} />
<ControlButtonBox
layout={props.layout}
updateLayout={props.updateLayout}
setShowInlineSearch={setShowInlineSearch}
/>
</>
)}
</HStack>
)
}
type MenuHeaderButtonProps = {
showFilterMenu: boolean
setShowFilterMenu: (show: boolean) => void
}
export function MenuHeaderButton(props: MenuHeaderButtonProps): JSX.Element {
return (
<HStack
css={{
ml: '10px',
width: '67px',
height: '40px',
bg: props.showFilterMenu ? '$thTextContrast2' : '$thBackground2',
borderRadius: '5px',
px: '5px',
cursor: 'pointer',
}}
alignment="center"
distribution="around"
onClick={() => {
props.setShowFilterMenu(!props.showFilterMenu)
}}
>
<OmnivoreSmallLogo
size={20}
strokeColor={
props.showFilterMenu
? theme.colors.thBackground.toString()
: theme.colors.thTextContrast2.toString()
}
/>
<FunnelSimple
size={20}
color={
props.showFilterMenu
? theme.colors.thBackground.toString()
: theme.colors.thTextContrast2.toString()
}
/>
</HStack>
)
}
export type SearchBoxProps = {
searchTerm: string | undefined
applySearchQuery: (searchQuery: string) => void
compact?: boolean
onClose?: () => void
}
export function SearchBox(props: SearchBoxProps): JSX.Element {
const inputRef = useRef<HTMLInputElement | null>(null)
const [focused, setFocused] = useState(false)
const [searchTerm, setSearchTerm] = useState(props.searchTerm ?? '')
const border = props.compact
? focused
? '1px solid $omnivoreCtaYellow'
: '1px solid black'
: focused
? '1px solid $omnivoreCtaYellow'
: '1px solid $thBorderColor'
useKeyboardShortcuts(
searchBarCommands((action) => {
if (action === 'focusSearchBar' && inputRef.current) {
inputRef.current.select()
}
if (action == 'clearSearch' && inputRef.current) {
setSearchTerm('')
props.applySearchQuery('')
}
})
)
return (
<Box
css={{
height: '38px',
width: '100%',
maxWidth: '521px',
bg: props.compact ? 'white' : '$thBackground2',
borderRadius: '6px',
border: border,
}}
>
<HStack
alignment="center"
distribution="start"
css={{ width: '100%', height: '100%' }}
>
<HStack
alignment="center"
distribution="start"
css={{
height: '100%',
pl: props.compact ? '10px' : '15px',
pr: props.compact ? '5px' : '10px',
}}
onClick={(e) => {
inputRef.current?.focus()
e.preventDefault()
}}
>
<MagnifyingGlass
size={props.compact ? 15 : 20}
color={theme.colors.graySolid.toString()}
/>
</HStack>
<form
onSubmit={(event) => {
event.preventDefault()
props.applySearchQuery(searchTerm || '')
inputRef.current?.blur()
if (props.onClose) {
props.onClose()
}
}}
style={{ width: '100%' }}
>
<FormInput
ref={inputRef}
type="text"
value={searchTerm}
autoFocus={!!props.compact}
placeholder="Search keywords or labels"
onFocus={(event) => {
event.target.select()
setFocused(true)
}}
onBlur={() => {
setFocused(false)
}}
onChange={(event) => {
setSearchTerm(event.target.value)
}}
onKeyDown={(event) => {
const key = event.key.toLowerCase()
if (key == 'escape') {
event.currentTarget.blur()
}
}}
/>
</form>
{searchTerm && searchTerm.length ? (
<Box
css={{
py: '15px',
marginLeft: 'auto',
}}
>
<IconButton
style="searchButton"
onClick={(event) => {
event.preventDefault()
setSearchTerm('')
props.applySearchQuery('')
inputRef.current?.blur()
}}
tabIndex={-1}
>
<X
width={16}
height={16}
color={theme.colors.grayTextContrast.toString()}
/>
</IconButton>
</Box>
) : (
<Box
css={{
py: '15px',
marginLeft: 'auto',
}}
>
<IconButton
style="searchButton"
onClick={() =>
requestAnimationFrame(() => inputRef?.current?.focus())
}
tabIndex={-1}
>
<kbd aria-hidden>/</kbd>
</IconButton>
</Box>
)}
</HStack>
</Box>
)
}
type ControlButtonBoxProps = {
layout: LayoutType
updateLayout: (layout: LayoutType) => void
setShowInlineSearch?: (show: boolean) => void
}
function ControlButtonBox(props: ControlButtonBoxProps): JSX.Element {
return (
<>
<HStack
alignment="center"
distribution="end"
css={{
marginLeft: 'auto',
marginRight: '45px',
width: '100px',
height: '100%',
gap: '20px',
minWidth: '121px',
'@mdDown': {
display: 'none',
},
}}
>
<Button
style="plainIcon"
css={{ display: 'flex' }}
onClick={(e) => {
props.updateLayout('LIST_LAYOUT')
e.preventDefault()
}}
>
<ListSelectorIcon
color={props.layout == 'GRID_LAYOUT' ? '#6A6968' : '#FFEA9F'}
/>
</Button>
<Button
style="plainIcon"
css={{ display: 'flex' }}
onClick={(e) => {
props.updateLayout('GRID_LAYOUT')
e.preventDefault()
}}
>
<GridSelectorIcon
color={props.layout == 'LIST_LAYOUT' ? '#6A6968' : '#FFEA9F'}
/>
</Button>
<PrimaryDropdown showThemeSection={true} />
</HStack>
{props.setShowInlineSearch && (
<HStack
alignment="center"
distribution="end"
css={{
marginLeft: 'auto',
marginRight: '20px',
width: '100px',
height: '100%',
gap: '20px',
'@md': {
display: 'none',
},
}}
>
<Button
style="ghost"
onClick={() => {
props.setShowInlineSearch && props.setShowInlineSearch(true)
}}
css={{
display: 'flex',
}}
>
<MagnifyingGlass
size={20}
color={theme.colors.graySolid.toString()}
/>
</Button>
<PrimaryDropdown
showThemeSection={true}
layout={props.layout}
updateLayout={props.updateLayout}
/>
</HStack>
)}
</>
)
}

View file

@ -40,14 +40,6 @@ export function LibrarySearchBar(props: LibrarySearchBarProps): JSX.Element {
setSearchTerm(props.searchTerm || '')
}, [props.searchTerm])
useKeyboardShortcuts(
searchBarCommands((action) => {
if (action === 'focusSearchBar' && inputRef.current) {
inputRef.current.select()
}
})
)
return (
<VStack css={{ width: '100%' }}>
<HStack

View file

@ -1,6 +1,5 @@
import { useCallback, useMemo, useState } from 'react'
import { styled } from '@stitches/react'
import Link from 'next/link'
import Image from 'next/image'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
@ -9,7 +8,10 @@ import { StyledText } from '../../elements/StyledText'
import { FormInput } from '../../elements/FormElements'
import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation'
import { Integration, useGetIntegrationsQuery } from '../../../lib/networking/queries/useGetIntegrationsQuery'
import {
Integration,
useGetIntegrationsQuery,
} from '../../../lib/networking/queries/useGetIntegrationsQuery'
import { useRouter } from 'next/router'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation'
@ -22,7 +24,7 @@ const Header = styled(Box, {
})
export function Readwise(): JSX.Element {
const { integrations, isValidating, revalidate } = useGetIntegrationsQuery()
const { integrations, revalidate } = useGetIntegrationsQuery()
const readwiseIntegration = useMemo(() => {
return integrations.find((i) => i.type == 'READWISE')
}, [integrations])
@ -57,23 +59,23 @@ export function Readwise(): JSX.Element {
</HStack>
{readwiseIntegration && (
<RemoveReadwiseForm integration={readwiseIntegration} revalidate={revalidate} />
)}
{!readwiseIntegration && (
<AddReadwiseForm />
<RemoveReadwiseForm
integration={readwiseIntegration}
revalidate={revalidate}
/>
)}
{!readwiseIntegration && <AddReadwiseForm />}
</VStack>
)
}
function AddReadwiseForm(): JSX.Element {
const router = useRouter()
const [errorMessage, setErrorMessage] =
useState<string | undefined>(undefined)
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined
)
const [token, setToken] = useState<string>('')
const setReadwiseToken = useCallback(async () => {
@ -94,64 +96,64 @@ function AddReadwiseForm(): JSX.Element {
}
}, [token, router])
return (<>
<HStack
css={{
fontSize: '18px',
color: '$utilityTextDefault',
my: '20px',
whiteSpace: 'pre-wrap'
}}
>
<SpanBox>Enter your API key from Readwise below. You can get your token{' '}
<a
target='_blank'
rel="noreferrer"
referrerPolicy='no-referrer'
style={{ color: '$utilityTextDefault' }}
href="https://readwise.io/access_token"
return (
<>
<HStack
css={{
fontSize: '18px',
color: '$utilityTextDefault',
my: '20px',
whiteSpace: 'pre-wrap',
}}
>
here
</a>.</SpanBox>
</HStack>
<SpanBox>
Enter your API key from Readwise below. You can get your token{' '}
<a
target="_blank"
rel="noreferrer"
referrerPolicy="no-referrer"
style={{ color: '$utilityTextDefault' }}
href="https://readwise.io/access_token"
>
here
</a>
.
</SpanBox>
</HStack>
<FormInput
type="token"
key="token"
value={token}
placeholder={'Readwise Token'}
onChange={(e) => {
e.preventDefault()
setToken(e.target.value)
}}
disabled={false}
hidden={false}
required={true}
css={{
border: '1px solid $textNonessential',
borderRadius: '8px',
width: '80%',
bg: 'transparent',
fontSize: '16px',
textIndent: '8px',
my: '20px',
height: '38px',
color: '$grayTextContrast',
'&:focus': {
outline: 'none',
boxShadow: '0px 0px 2px 2px rgba(255, 234, 159, 0.56)',
},
}}
min={200}
/>
{errorMessage && <StyledText style="error">{errorMessage}</StyledText>}
<Button
style="ctaDarkYellow"
css={{ }}
onClick={setReadwiseToken}
>
Set Token
</Button>
<FormInput
type="token"
key="token"
value={token}
placeholder={'Readwise Token'}
onChange={(e) => {
e.preventDefault()
setToken(e.target.value)
}}
disabled={false}
hidden={false}
required={true}
css={{
border: '1px solid $textNonessential',
borderRadius: '8px',
width: '80%',
bg: 'transparent',
fontSize: '16px',
textIndent: '8px',
my: '20px',
height: '38px',
color: '$grayTextContrast',
'&:focus': {
outline: 'none',
boxShadow: '0px 0px 2px 2px rgba(255, 234, 159, 0.56)',
},
}}
min={200}
/>
{errorMessage && <StyledText style="error">{errorMessage}</StyledText>}
<Button style="ctaDarkYellow" css={{}} onClick={setReadwiseToken}>
Set Token
</Button>
</>
)
}
@ -179,19 +181,23 @@ function RemoveReadwiseForm(props: RemoveReadwiseFormProps): JSX.Element {
}, [props])
return (
<>
<HStack
css={{
fontSize: '18px',
color: '$utilityTextDefault',
my: '20px',
whiteSpace: 'pre-wrap'
}}
>
<SpanBox>Omnivore is configured to send all your highlights to Readwise.</SpanBox>
</HStack>
<>
<HStack
css={{
fontSize: '18px',
color: '$utilityTextDefault',
my: '20px',
whiteSpace: 'pre-wrap',
}}
>
<SpanBox>
Omnivore is configured to send all your highlights to Readwise.
</SpanBox>
</HStack>
<Button style="ctaDarkYellow" onClick={deleteIntegration}>Remove Readwise Integration</Button>
</>
<Button style="ctaDarkYellow" onClick={deleteIntegration}>
Remove Readwise Integration
</Button>
</>
)
}
}

View file

@ -1,7 +1,5 @@
import Link from 'next/link'
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
import { GithubLogo, DiscordLogo, TwitterLogo } from 'phosphor-react'
import { styled, theme } from '../../tokens/stitches.config'
import { HStack, VStack } from '../../elements/LayoutPrimitives'
import { styled } from '../../tokens/stitches.config'
import { StyledText } from '../../elements/StyledText'
const containerStyles = {

View file

@ -1,63 +0,0 @@
import { SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
import { styled } from '../../tokens/stitches.config'
import { Root, Image, Fallback } from '@radix-ui/react-avatar'
type AvatarProps = {
viewer?: UserBasicData
}
export function LibraryAvatar(props: AvatarProps): JSX.Element {
return (
<VStack alignment="center" distribution="start" css={{ pl: '8px', width: '100%', height: '100%' }}>
<VStack css={{ height: '100%' }}>
<StyledAvatar
css={{
width: '40px',
height: '40px',
borderRadius: '50%',
}}
>
{props.viewer?.profile.pictureUrl
? <StyledImage src={props.viewer.profile.pictureUrl} />
: <StyledFallback>{props.viewer?.name.charAt(0) ?? ''}</StyledFallback>
}
</StyledAvatar>
</VStack>
{/* This spacer is to help align with items in the search box */}
<SpanBox css={{ marginTop: 'auto', height: '10px', width: '100%' }} />
</VStack>
)
}
const StyledAvatar = styled(Root, {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
verticalAlign: 'middle',
overflow: 'hidden',
userSelect: 'none',
})
const StyledImage = styled(Image, {
width: '100%',
height: '100%',
objectFit: 'cover',
'&:hover': {
opacity: '48%',
},
})
const StyledFallback = styled(Fallback, {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '16px',
fontWeight: '600',
color: '$utilityTextDefault',
backgroundColor: '$libraryActiveMenuItem',
})

View file

@ -1,76 +0,0 @@
import { HStack, SpanBox, VStack } from './../../elements/LayoutPrimitives'
import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery'
import { useGetUserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
import { LibraryMenu } from './LibraryMenu'
import { LibraryAvatar } from './LibraryAvatar'
import { LibrarySearchBar } from './LibrarySearchBar'
import { LibraryList } from './LibraryList'
import { LibraryHeadline } from './LibraryHeadline'
import { useCallback, useState } from 'react'
import { LibraryItemsQueryInput } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
export type SearchCoordinator = {
applySearch: (searchTerm: string) => void
}
const useSearchCoordinator = () => {
const applySearch = useCallback((searchTerm: string) => {
console.log('applying search')
}, [])
return {
applySearch
}
}
export type LibraryLayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
export type LayoutCoordinator = {
layout: LibraryLayoutType
setLayout: (type: LibraryLayoutType) => void
}
const useLibraryLayoutCoordinator = () => {
const [layout, setLayout] = usePersistedState<LibraryLayoutType>({
key: 'libraryLayout',
initialValue: 'GRID_LAYOUT',
})
return {
layout,
setLayout
}
}
export function LibraryContainer(): JSX.Element {
useGetUserPreferences()
const { viewerData } = useGetViewerQuery()
const searchCoordinator = useSearchCoordinator()
const layoutCoordinator = useLibraryLayoutCoordinator()
return (
<>
<VStack alignment="start" distribution="start" css={{ width: '100vw', height: '100vh', overflow: 'hidden', bg: '$libraryBackground' }}>
<HStack alignment="start" distribution="start" css={{ width: '100%' }}>
<SpanBox css={{ width: '100%', height: '90px' }}>
<LibrarySearchBar coordinator={searchCoordinator} />
</SpanBox>
<SpanBox css={{ marginLeft: 'auto', width: '130px', height: '100%' }}>
<LibraryAvatar viewer={viewerData?.me} />
</SpanBox>
</HStack>
<HStack alignment="start" distribution="start" css={{ width: '100%', height: '100%' }}>
<LibraryMenu />
<VStack alignment="start" distribution="start" css={{ width: '100%', height: '100%', mr: '20px' }}>
<LibraryHeadline layoutCoordinator={layoutCoordinator} />
<LibraryList layoutCoordinator={layoutCoordinator} />
</VStack>
</HStack>
</VStack>
</>
)
}

View file

@ -1,43 +0,0 @@
import { HStack, SpanBox } from '../../elements/LayoutPrimitives'
import { useGetUserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
import { StyledText } from '../../elements/StyledText'
import { theme } from '../../tokens/stitches.config'
import { LibraryListLayoutIcon } from '../../elements/images/LibraryListLayoutIcon'
import { LibraryGridLayoutIcon } from '../../elements/images/LibraryGridLayoutIcon'
import { Button } from '../../elements/Button'
import { LayoutCoordinator, LibraryLayoutType } from './LibraryContainer'
import { useCallback } from 'react'
import { Plus } from 'phosphor-react'
export type LibraryHeadlineProps = {
layoutCoordinator: LayoutCoordinator
}
export function LibraryHeadline(props: LibraryHeadlineProps): JSX.Element {
const typeColor = useCallback((type: LibraryLayoutType) => {
return (
props.layoutCoordinator.layout === type
? theme.colors.omnivoreCtaYellow.toString()
: "#D6D6D6"
)
}, [props.layoutCoordinator.layout])
return (
<HStack alignment="center" distribution="start" css={{ pt: '8px', pb: '14px', width: '100%', pr: '15px' }}>
<StyledText style="libraryHeader">Home</StyledText>
<HStack alignment="center" distribution="start" css={{ marginLeft: 'auto', gap: '8px' }}>
<Button style="ctaDarkYellow" css={{ py: '10px', px: '14px', mr: '16px', display: 'flex', alignItems: 'center' }}>
<Plus size={16} weight='bold' />
<SpanBox css={{ pl: '10px', fontWeight: '600', fontSize: '16px' }}>Add Link</SpanBox>
</Button>
<Button style="ghost" onClick={() => props.layoutCoordinator.setLayout('LIST_LAYOUT')} css={{ display: 'flex', alignItems: 'center' }}>
<LibraryListLayoutIcon color={typeColor('LIST_LAYOUT')} />
</Button>
<Button style="ghost" onClick={() => props.layoutCoordinator.setLayout('GRID_LAYOUT')} css={{ display: 'flex', alignItems: 'center' }}>
<LibraryGridLayoutIcon color={typeColor('GRID_LAYOUT')} />
</Button>
</HStack>
</HStack>
)
}

View file

@ -1,172 +0,0 @@
import { useMemo, useState } from 'react'
import Dropzone from 'react-dropzone'
import { Box } from '../../elements/LayoutPrimitives'
import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery'
import { useGetUserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
import { useGetLibraryItemsQuery } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { LinkedItemCardAction } from '../../patterns/LibraryCards/CardTypes'
import { LibraryGridCard } from '../../patterns/LibraryCards/LibraryGridCard'
import { LayoutCoordinator } from './LibraryContainer'
import { EmptyLibrary } from '../homeFeed/EmptyLibrary'
import Masonry from 'react-masonry-css'
export type LibraryListProps = {
layoutCoordinator: LayoutCoordinator
}
export function LibraryList(props: LibraryListProps): JSX.Element {
useGetUserPreferences()
const { viewerData } = useGetViewerQuery()
const defaultQuery = {
limit: 50,
sortDescending: true,
searchQuery: undefined,
}
const { itemsPages, size, setSize, isValidating, performActionOnItem } =
useGetLibraryItemsQuery(defaultQuery)
const [fileNames, setFileNames] = useState([])
const [inDragOperation, setInDragOperation] = useState(false)
const [uploadingFiles, setUploadingFiles] = useState([])
const handleDrop = (acceptedFiles: any) => {
setFileNames(acceptedFiles.map((file: { name: any }) => file.name))
setUploadingFiles(acceptedFiles.map((file: { name: any }) => file.name))
}
const libraryItems = useMemo(() => {
const items =
itemsPages?.flatMap((ad) => {
return ad.search.edges
}) || []
return items
}, [itemsPages, performActionOnItem])
if (!isValidating && libraryItems.length == 0) {
return (
<EmptyLibrary
onAddLinkClicked={() => {
console.log('onAddLinkClicked')
}}
/>
)
}
console.log(fileNames)
return (
<Box css={{ overflowY: 'scroll' }}>
{inDragOperation && uploadingFiles.length < 1 && (
<Box
css={{
border: '3px dashed gray',
backgroundColor: 'aliceblue',
borderRadius: '5px',
width: '75%',
height: '70%',
position: 'absolute',
opacity: '0.8',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Box
css={{
color: '$utilityTextDefault',
fontWeight: '800',
fontSize: '$4',
}}
>
Drag n drop files here
</Box>
</Box>
)}
<Dropzone
onDrop={handleDrop}
preventDropOnDocument={true}
onDragEnter={() => {
setInDragOperation(true)
}}
onDragLeave={() => {
setInDragOperation(false)
}}
noClick={true}
noDragEventsBubbling={true}
>
{({ getRootProps, getInputProps, acceptedFiles, fileRejections }) => (
<Box
{...getRootProps({ className: 'dropzone' })}
css={{ width: '100%' }}
>
<input {...getInputProps()} />
<Masonry
breakpointCols={
props.layoutCoordinator.layout == 'LIST_LAYOUT'
? 1
: {
default: 3,
1200: 2,
992: 1,
}
}
className="omnivore-masonry-grid"
columnClassName="omnivore-masonry-grid_column"
>
{libraryItems.map((linkedItem) => (
<Box
className="linkedItemCard"
data-testid="linkedItemCard"
id={linkedItem.node.id}
tabIndex={0}
key={linkedItem.node.id}
css={{
width: '100%',
'&> div': {
bg: '$libraryBackground',
},
'&:focus': {
'> div': {
bg: '$grayBgActive',
},
},
'&:hover': {
'> div': {
bg: '$grayBgActive',
},
},
}}
>
{viewerData?.me && (
<LibraryGridCard
layout={props.layoutCoordinator.layout}
item={linkedItem.node}
viewer={viewerData.me}
handleAction={(action: LinkedItemCardAction) => {
console.log('card clicked')
}}
/>
)}
</Box>
))}
</Masonry>
</Box>
)}
</Dropzone>
{/* Temporary code */}
<div>
<strong>Files:</strong>
<ul>
{fileNames.map((fileName) => (
<li key={fileName}>{fileName}</li>
))}
</ul>
</div>{' '}
{/* Temporary code */}
{/* Extra padding at bottom to give space for scrolling */}
<Box css={{ width: '100%', height: '200px' }} />
</Box>
)
}

View file

@ -1,24 +0,0 @@
import { VStack } from '../../elements/LayoutPrimitives'
import { useGetUserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
import { Menubar } from '../Menu'
export function LibraryMenu(): JSX.Element {
useGetUserPreferences()
return (
<VStack
alignment="start"
distribution="start"
css={{
width: '286px',
minWidth: '286px',
pl:'15px',
height: 'calc(100% - 100px)',
overflowY: 'auto',
fontWeight: '600',
}}
>
<Menubar />
</VStack>
)
}

View file

@ -1,215 +0,0 @@
import { useState, useEffect } from 'react'
import { Clock, Sliders, X } from 'phosphor-react'
import Downshift from 'downshift'
import { HStack, SpanBox, VStack } from './../../elements/LayoutPrimitives'
import { FormInput } from '../../elements/FormElements'
import { Button } from '../../elements/Button'
import { styled, theme } from '../../tokens/stitches.config'
import { SearchCoordinator } from './LibraryContainer'
// Styles
const List = styled('ul', {
width: '91%',
maxHeight: '400px',
overflow: 'auto',
top: '65px',
left: '-32px',
color: 'var(--colors-utilityTextDefault)',
backgroundColor: 'var(--colors-grayBase)',
position: 'absolute',
zIndex: '2',
'@smDown': {
fontSize: 16,
},
})
const Item = styled('li', {
listStyleType: 'none',
m: '8px',
borderRadius: '5px',
width: '100%',
})
export type LibrarySearchBarProps = {
coordinator: SearchCoordinator
}
export function LibrarySearchBar(props: LibrarySearchBarProps): JSX.Element {
const [recentSearches, setRecentSearches] = useState(Array<unknown[]>())
useEffect(() => {
setRecentSearches(Object.values(localStorage))
}, [])
return (
<Downshift itemToString={(item) => (item ? item : '')}>
{({
getInputProps,
getRootProps,
getMenuProps,
getItemProps,
isOpen,
highlightedIndex,
inputValue,
clearSelection,
openMenu,
}) => (
<VStack
alignment="start"
distribution="start"
css={{ pl: '32px', width: '100%', height: '100%' }}
>
<HStack
alignment="start"
distribution="start"
css={{ width: '100%', borderBottom: 'solid 1px $grayBorder' }}
{...getRootProps({ refKey: 'ref' }, { suppressRefError: true })}
>
<form
style={{ width: '100%' }}
onSubmit={(event) => {
event.preventDefault()
// props.applySearchQuery(searchTerm || '')
// inputRef.current?.blur()
}}
{...getRootProps()}
>
<FormInput
css={{
width: '77%',
height: '80px',
fontSize: '24px',
fontFamily: 'Inter',
}}
type="text"
tabIndex={0}
value={inputValue}
placeholder="Search"
onFocus={(event: any) => {
event.preventDefault()
openMenu()
//props.applySearchQuery('')
// inputRef.current?.blur()
}}
onChange={(event: any) => {
event.preventDefault()
}}
{...getInputProps()}
/>
{/* {searchTerm && ( */}
<HStack
alignment="center"
distribution="start"
css={{
height: '100%',
display: 'flex',
justifyContent: 'right',
alignItems: 'center',
margin: '-57px 0 10px',
}}
>
<Button
style="plainIcon"
onClick={(event) => {
event.preventDefault()
clearSelection()
//props.applySearchQuery('')
// inputRef.current?.blur()
}}
css={{
mr: '15px',
}}
>
<X
width={24}
height={24}
color={theme.colors.grayTextContrast.toString()}
/>
</Button>
<Button
style="ctaDarkYellow"
type="submit"
css={{
mr: '15px',
}}
onClick={(event) => {
event.preventDefault()
if (inputValue && inputValue.length > 0) {
localStorage.setItem(inputValue, inputValue)
setRecentSearches(Object.values(localStorage))
}
// props.applySearchQuery('')
// inputRef.current?.blur()
}}
>
Search
</Button>
{/* )} */}
{/* {!searchTerm && ( */}
<Button
style="plainIcon"
onClick={(event) => {
// Display the advanced search sheet
}}
>
<Sliders
size={24}
color={theme.colors.utilityTextDefault.toString()}
/>
</Button>
{/* )} */}
</HStack>
<List>
{isOpen &&
recentSearches
.filter((item) => !inputValue || item.includes(inputValue))
.map((item, index) => (
<SpanBox
{...getMenuProps()}
key={item}
css={{
paddingLeft: '15px',
display: 'flex',
alignItems: 'center',
borderBottom: '1px solid $grayBorder',
'& svg': {
margin: '10px',
},
backgroundColor:
index === highlightedIndex
? 'var(--colors-grayBg)'
: 'transparent',
}}
>
<Clock size={20} />
<Item
{...getItemProps({
item,
index,
})}
>
{item}
</Item>
<X
width={20}
height={20}
color={theme.colors.grayTextContrast.toString()}
onClick={() => {
localStorage.removeItem(`${item}`)
setRecentSearches(Object.values(localStorage))
}}
/>
</SpanBox>
))}
</List>
</form>
</HStack>
</VStack>
)}
</Downshift>
)
}

View file

@ -1,89 +0,0 @@
import React, { useState } from 'react'
import { subscribeMutation } from '../../../lib/networking/mutations/subscribeMutation'
import { VStack } from '../../elements/LayoutPrimitives'
import { OnboardingLayout } from '../OnboardingLayout'
import { SelectOption } from './SelectOption'
const newsletterOptions = [
{
icon: 'AxiosDaily.png',
label: 'Axios Daily Essentials',
description: 'Start and end your day with the stories that matter in your inbox.',
name: 'axios_essentials',
isChecked: false,
},
{
icon: 'MilkRoad.png',
label: 'Milk Road',
description: '5 minute daily newsletter. Used by 100,000+ people to be better crypto investors 💪',
name: 'morning_brew',
isChecked: false,
},
{
icon: 'MoneyStuff.png',
label: 'Money Stuff by Matt Levine',
description: 'A daily take on Wall Street, finance, companies and other stuff.',
name: 'milk_road',
isChecked: false,
},
{
icon: 'OmnivoreBlog.png',
label: 'Omnivore',
description: 'Tips and tricks, plus updates on new features in Omnivore.',
name: 'omnivore_blog',
isChecked: false,
},
]
type OnboardingAddNewslettersProps = {
pageNumber: number
}
export type NewsLetterOption = {
icon: string,
label: string,
name: string,
description: string,
isChecked: boolean,
}
export const OnboardingAddNewsletters = (props: OnboardingAddNewslettersProps) => {
const [newsletters, setNewsletters] = useState<NewsLetterOption[]>(newsletterOptions);
const onCheck = (index: number) => {
const temp = [...newsletters]
temp[index].isChecked = !temp[index].isChecked
setNewsletters(temp)
}
const onNext = () => {
newsletters.map((newsletter) => {
if (newsletter.isChecked) subscribeMutation(newsletter.name)
})
}
return (
<OnboardingLayout
pageNumber={props.pageNumber}
subTitle="Omnivore creates an email address for you to subscribe to newsletters with."
description="Subscribe to some newsletters now"
title="Read all your Newsletters in Omnivore"
onNext={onNext}
>
<VStack css={{
marginTop: '$4',
width: '100%'
}}>
{newsletters.map(({ icon, label, description, isChecked }, idx) => (
<SelectOption
key={idx}
indexNum={idx}
onCheck={onCheck}
{...{ icon, label, description, isChecked}} />
))}
</VStack>
</OnboardingLayout>
)
}

View file

@ -1,43 +0,0 @@
import React from 'react'
import { OnboardingLayout } from '../OnboardingLayout'
import { Box } from '../../elements/LayoutPrimitives'
import { styled } from '../../tokens/stitches.config'
const StyledImage = styled('img', {
position: 'relative',
width: '100%',
height: '100%',
'@smDown': {
width: '160%',
height: 'auto',
}
})
type OnboardingOrganizeInstructionsProps = {
pageNumber: number
}
export const OnboardingHighlightInstructions = (props: OnboardingOrganizeInstructionsProps) => {
return (
<OnboardingLayout
pageNumber={props.pageNumber}
title="Highlight and share"
subTitle="Information about creating and sharing highlights Amet minim mollit non deserunt ullamco est sit aliqua dolor do amet sint. Velit officia consequat duis enim velit mollit. Exercitation veniam consequat sunt nostrud amet."
>
<Box css={{
width: '100%',
height: '100%',
overflow: 'hidden',
'@smDown': {
width: '100%',
height: 'auto',
}
}}>
<StyledImage
src='/static/images/onboarding/highlight-and-share.svg'
alt="Highlight And Share"
/>
</Box>
</OnboardingLayout>
)
}

View file

@ -1,100 +0,0 @@
import React from 'react'
import { OnboardingLayout } from '../OnboardingLayout'
import MobileInstallHelp from '../../elements/MobileInstallHelp'
import ExtensionsInstallHelp from '../../elements/ExtensionsInstallHelp'
import { Box } from '../../elements/LayoutPrimitives'
import { sendInstallInstructions } from '../../../lib/networking/queries/sendInstallInstructions'
import { Button } from '../../elements/Button'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
type OnboardingInstallInstructionsProps = {
pageNumber: number
}
export const OnboardingInstallInstructions = (props: OnboardingInstallInstructionsProps) => {
const onEmailInstructionsClick = async () => {
const res = await sendInstallInstructions()
if (res !== undefined) {
showSuccessToast('Instructions Email Sent', { position: 'bottom-right' })
}
else {
showErrorToast('Failed to send', { position: 'bottom-right' })
}
}
return (
<OnboardingLayout
pageNumber={props.pageNumber}
title="Save links to read later"
subTitle="Save any link to your library using our apps and browser extensions"
description='Install our apps and browser extensions'
image={
<img
src='/static/images/onboarding/browser-extension.svg'
alt="Browser Extension"
/>
}
>
<Box css={{
"@lgDown": {
marginBottom: 100,
}
}}>
<Box
css={{
margin: '27px 0',
'@lg': {
border: '1px solid rgba(0, 0, 0, 0.06)',
boxShadow: '0px 3px 11px rgba(32, 31, 29, 0.04)',
},
backgroundColor: 'White',
}}
>
<Box
css={{
'@lgDown': {
boxShadow: '0px 3px 11px rgba(32, 31, 29, 0.04)',
border: '1px solid rgba(0, 0, 0, 0.06)',
},
}}
>
<MobileInstallHelp onboarding />
</Box>
<Box
css={{
'@lgDown': {
boxShadow: '0px 3px 11px rgba(32, 31, 29, 0.04)',
border: '1px solid rgba(0, 0, 0, 0.06)',
marginTop: '8px',
},
}}
>
<ExtensionsInstallHelp onboarding />
</Box>
</Box>
<Box
css={{
'@lg': {
marginBottom: '20px',
},
}}
>
<Box
onClick={onEmailInstructionsClick}
css={{
fontWeight: '400',
fontSize: '14px',
lineHeight: '17.5px',
color: 'rgba(10, 8, 6, 0.8)',
textDecoration: 'underline',
cursor: 'pointer',
}}
>
Email me instructions
</Box>
</Box>
</Box>
</OnboardingLayout>
)
}

View file

@ -1,77 +0,0 @@
import React from 'react'
import { OnboardingLayout } from '../OnboardingLayout'
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
import { styled } from '../../tokens/stitches.config'
const IconContainer = styled(Box, {width: '30%', justifyContent: 'center', display: 'flex'})
const Icon = styled('img', {width: 100})
const Row = styled(HStack, {
width: '100%',
padding: 30,
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
'@smDown': {
padding: 20,
}
})
const Text = styled(Box, {
width: '65%',
alignSelf: 'center',
color: '#0A0806CC',
fontSize: 24,
fontWeight: '700',
'@smDown': {
fontSize: 16,
}
})
const Container = styled(Box, {
width: 523,
border: '1px solid #0000000F',
background: 'white',
'@smDown': {
width: '95%',
}
})
type OnboardingJoinCommunityProps = {
pageNumber: number
}
export const OnboardingJoinCommunity = (props: OnboardingJoinCommunityProps) => {
return (
<OnboardingLayout
pageNumber={props.pageNumber}
title="Join our Community"
subTitle='Omnivore is open source and open community, join us.'
nextPage='/home'
reduceSpace
>
<HStack distribution='center' alignment='center' css={{width: '100%', height: '100%'}}>
<Container>
<VStack>
<Row distribution='between'>
<IconContainer>
<Icon css={{height: 60}} src='/static/images/onboarding/github.svg' alt="Github Logo" />
</IconContainer>
<Text>Star us on Github</Text>
</Row>
<Row distribution='between'>
<IconContainer>
<Icon src='/static/images/onboarding/discord.svg' alt="Discrod Logo" />
</IconContainer>
<Text>Join us on Discord</Text>
</Row>
<Row distribution='between'>
<IconContainer>
<Icon css={{width: 74, height: 67}} src='/static/images/onboarding/producthunt.svg' alt="ProductHunt Logo" />
</IconContainer>
<Text>Like us on Product Hunt</Text>
</Row>
</VStack>
</Container>
</HStack>
</OnboardingLayout>
)
}

View file

@ -1,57 +0,0 @@
import React from 'react'
import { OnboardingLayout } from '../OnboardingLayout'
import { Box } from '../../elements/LayoutPrimitives'
import { styled } from '../../tokens/stitches.config'
const StyledImage = styled('img', {
position: 'relative',
})
type OnboardingOrganizeInstructionsProps = {
pageNumber: number
}
export const OnboardingOrganizeInstructions = (props: OnboardingOrganizeInstructionsProps) => {
return (
<OnboardingLayout
pageNumber={props.pageNumber}
title="Organize all your content"
subTitle="Information about archiving and tagging
Amet minim mollit non deserunt ullamco est sit aliqua dolor do amet sint. Velit officia consequat duis enim velit mollit. Exercitation veniam consequat sunt nostrud amet."
>
<Box css={{width: '100%', height: '100%'}}>
<StyledImage
src='/static/images/onboarding/organize-content.svg'
alt="Organize Content Desktop"
css={{
width: '100%',
height: '500px',
'@smDown': {
display: 'none',
},
'@lg': {
height: '75%',
},
'@xl': {
width: 'auto',
height: '95%',
}
}}
/>
<StyledImage
src='/static/images/onboarding/organize-content-mobile.svg'
alt="Organize Content Mobile"
css={{
mt: 10,
right: '1.6vh',
objectFit: 'contain',
width: '100%',
'@sm': {
display: 'none',
}
}}
/>
</Box>
</OnboardingLayout>
)
}

View file

@ -1,83 +0,0 @@
import { SelectionOptionCard } from './SelectOption'
import { OnboardingLayout } from '../OnboardingLayout'
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
const articleDetails = [
{
title: 'Winnebago Electric RV Concept',
author: 'Omnivore',
originText: 'wired.com',
description: "An incredible number of lines from William Shakespeare's plays have becomeso ingrained in modern vernacular …",
image: "https://images.hgmsites.net/sml/cadillac_100789665_s.jpg",
labels: []
},
{
title: 'Winnebago Electric RV Concept',
author: 'Omnivore',
originText: 'wired.com',
description: "An incredible number of lines from William Shakespeare's plays have becomeso ingrained in modern vernacular …",
image: "https://images.hgmsites.net/sml/cadillac_100789665_s.jpg",
labels: []
},
{
title: '21 Phrases You Use Without Realizin…',
author: 'Omnivore',
originText: 'wired.com',
description: "An incredible number of lines from William Shakespeare's plays have becomeso ingrained in modern vernacular …",
image: "",
labels: [],
},
{
title: '21 Phrases You Use Without Realizin…',
author: 'Omnivore',
originText: 'wired.com',
description: "An incredible number of lines from William Shakespeare's plays have becomeso ingrained in modern vernacular …",
image: "https://images.hgmsites.net/sml/cadillac_100789665_s.jpg",
labels: []
},
]
type OnboardingReaderPreviewProps = {
pageNumber: number
}
export const OnboardingReaderPreview = (props: OnboardingReaderPreviewProps) => {
return (
<OnboardingLayout
pageNumber={props.pageNumber}
title="Read Distraction Free"
subTitle="Omnivore's distraction free reader gives you an uncluttered reading experience"
description='Add some great reads to your library now:'
image={
<img
src='/static/images/onboarding/expanded-controls.svg'
alt="Expanded control"
/>
}
>
<VStack css={{
marginTop: '$4',
marginBottom: '$6'
}}>
<HStack>
<Box css={{
display: 'grid',
gridTemplateColumns: '100%',
gridColumnGap: '$3',
gridRowGap: '$2',
'@md': {
gridTemplateColumns: '50% 50%',
},
}}>
{articleDetails.map(({ title, author, originText, description, image, labels }, idx) => (
<SelectionOptionCard
key={idx}
{...{ title, author, originText, description, image, labels }}
/>
))}
</Box>
</HStack>
</VStack>
</OnboardingLayout>
)
}

View file

@ -1,244 +0,0 @@
import React from 'react'
import Checkbox from '../../elements/Checkbox'
import { Box, VStack, HStack, SpanBox } from '../../elements/LayoutPrimitives'
import { CoverImage } from '../../elements/CoverImage'
import { StyledText } from '../../elements/StyledText'
import { authoredByText } from '../../patterns/ArticleSubtitle'
import { LabelChip } from '../../elements/LabelChip'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import Image from 'next/image'
export const SelectOption: React.FC<{
icon: string
label: string
description: string
onCheck: (idx: number) => void
indexNum: number
isChecked: boolean
}> = ({ icon, label, description, onCheck, indexNum, isChecked}) => {
const toggleChecked = () => {
onCheck(indexNum)
}
return (
<HStack onClick={toggleChecked} css={{
border: isChecked ? '1px solid #F9D354' : '1px solid #0000000F',
backgroundColor: isChecked ? '#FDFAEC' : '#FFFFFF',
boxShadow: '0px 3px 11px 0px #201F1D0A',
justifyContent: 'flex-start',
alignItems: 'center',
borderRadius: '6px',
cursor: 'pointer',
padding: '$2 $3',
marginBottom: 7,
width: '100%'
}}>
<Checkbox checked={isChecked} setChecked={() => undefined} />
<SpanBox css={{ marginLeft: '$2', width: '32px', height: '32px' }}>
<Image width={32} height={32} layout="fixed"
src={`/static/images/newsletter/${icon}`}
alt={`${icon.slice(0, -4)} logo`}
/>
</SpanBox>
<VStack css={{ border: '1', marginLeft: '$2', color: '#0A0806CC', }}>
<StyledText
css={{
margin: 0,
fontSize: 16,
fontWeight: 700,
}}
>
{label}
</StyledText>
<StyledText
css={{
margin: 0,
fontSize: 16,
fontWeight: 400,
color: '#0A0806CC',
}}
>
{description}
</StyledText>
</VStack>
</HStack>
)
}
export const SelectionOptionCard: React.FC <{
title: string,
author: string,
originText: string,
description: string,
image: string,
labels: Label[]
}> = ({title, author, originText, description, image, labels}) => {
const [checked, setChecked] = React.useState(false)
const toggleChecked = () => setChecked(!checked)
return (
<VStack
css={{
p: '$2',
pr: '8px',
height: '100%',
width: '100%',
maxWidth: '100%',
borderRadius: '6px',
cursor: 'pointer',
wordBreak: 'break-word',
overflow: 'clip',
boxShadow: '0px 3px 11px rgba(32, 31, 29, 0.04)',
position: 'relative',
marginRight: '$3',
border: checked ? '1px solid #F9D354' : '1px solid $grayBorder',
backgroundColor: checked ? '#FDFAEC' : '#FFFFFF',
}}
alignment="start"
distribution="start"
onClick={toggleChecked}
>
<Box
css={{
position: 'absolute',
top: '1px',
left: '1px',
width: 'calc(100% - 2px)',
'& > div': {
borderRadius: '100vmax 100vmax 0 0',
},
}}
>
</Box>
<VStack
distribution="start"
alignment="start"
css={{
px: '0px',
width: '100%',
pl: '$1',
}}
>
<HStack
alignment="start"
distribution="between"
css={{
width: '100%',
p: '0px',
mr: '-12px',
mt: '15px',
display: 'grid',
gridTemplateColumns: '1fr 24px',
gridTemplateRows: '1fr',
}}
>
<HStack css={{
alignItems: 'center'
}}>
<Checkbox {...{checked, setChecked}} />
<HStack css={{marginRight: '$2'}} />
<CardTitle title= {title} />
</HStack>
<Box
css={{ alignSelf: 'end', alignItems: 'start', height: '100%' }}
onClick={(e) => {
// This is here to prevent menu click events from bubbling
// up and causing us to "click" on the link item.
e.stopPropagation()
}}
>
</Box>
</HStack>
<HStack alignment="start" distribution="between">
<StyledText style="caption" css={{ my: '0' }}>
{author && (
<SpanBox css={{ mr: '8px' }}>
{authoredByText(author)}
</SpanBox>
)}
<SpanBox css={{ textDecorationLine: 'underline' }}>
{originText}
</SpanBox>
</StyledText>
</HStack>
</VStack>
<HStack
alignment="start"
distribution="between"
css={{
width: '100%',
pt: '$2',
px: '$1',
pr: '12px',
mt: '7px',
flexGrow: '1',
}}
>
<StyledText
css={{
m: 0,
py: '0px',
mr: '$2',
fontStyle: 'normal',
fontWeight: '400',
fontSize: '14px',
lineHeight: '125%',
color: '$grayTextContrast',
flexGrow: '4',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 5,
WebkitBoxOrient: 'vertical',
}}
data-testid="listDesc"
>
{description}
</StyledText>
{image && (
<CoverImage
src={image}
alt="Link Preview Image"
width={135}
height={90}
css={{ ml: '10px', mb: '8px', borderRadius: '3px' }}
onError={(e) => {
;(e.target as HTMLElement).style.display = 'none'
}}
/>
)}
</HStack>
<Box css={{ display: 'block', mt: '8px' }}>
{labels?.map(({ name, color }, index) => (
<LabelChip key={index} text={name || ''} color={color} />
))}
</Box>
</VStack>
)
}
type CardTitleProps = {
title: string
}
function CardTitle(props: CardTitleProps): JSX.Element {
return (
<StyledText
style="listTitle"
data-testid="listTitle"
css={{
mt: '0',
mb: '0',
fontWeight: '700',
textAlign: 'left',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
width: '100%',
overflow: 'hidden',
}}
>
{props.title}
</StyledText>
)
}

View file

@ -0,0 +1,116 @@
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { DotsThreeOutline, TextAa } from 'phosphor-react'
import { PrimaryDropdown } from '../PrimaryDropdown'
import { TooltipWrapped } from '../../elements/Tooltip'
import { LogoBox } from '../../elements/LogoBox'
import { ReactNode } from 'react'
import { HEADER_HEIGHT, MOBILE_HEADER_HEIGHT } from '../homeFeed/HeaderSpacer'
import { theme } from '../../tokens/stitches.config'
type ReaderHeaderProps = {
alwaysDisplayToolbar: boolean
showDisplaySettingsModal: (show: boolean) => void
children?: ReactNode
}
export function ReaderHeader(props: ReaderHeaderProps): JSX.Element {
return (
<>
<VStack
alignment="center"
distribution="start"
css={{
top: '0',
left: '0',
zIndex: 1,
position: 'fixed',
width: '100%',
height: HEADER_HEIGHT,
display: props.alwaysDisplayToolbar ? 'flex' : 'transparent',
pt: '35px',
borderBottom: props.alwaysDisplayToolbar
? '1px solid $thBorderColor'
: '1px solid transparent',
'@xlgDown': {
height: MOBILE_HEADER_HEIGHT,
pt: '0px',
bg: '$thBackground3',
borderBottom: '1px solid $thBorderColor',
},
}}
>
<HStack
alignment="center"
distribution="start"
css={{
width: '100%',
height: '100%',
}}
>
<LogoBox />
<SpanBox
css={{
width: '100%',
px: '25px',
'@lg': {
display: props.alwaysDisplayToolbar ? 'flex' : 'none',
},
'@mdDown': { px: '15px' },
}}
>
{props.children}
</SpanBox>
{!props.alwaysDisplayToolbar && (
<SpanBox
css={{
width: '100%',
'@lgDown': {
display: 'none',
},
}}
>
<ControlButtonBox {...props} />
</SpanBox>
)}
</HStack>
</VStack>
</>
)
}
function ControlButtonBox(props: ReaderHeaderProps): JSX.Element {
return (
<>
<HStack
alignment="center"
distribution="end"
css={{
marginLeft: 'auto',
marginRight: '25px',
width: '100px',
height: '100%',
gap: '20px',
minWidth: '121px',
}}
>
<Button
style="articleActionIcon"
onClick={() => {
props.showDisplaySettingsModal(true)
}}
>
<TooltipWrapped tooltipContent="Adjust Display Settings">
<TextAa size={25} color={theme.colors.thHighContrast.toString()} />
</TooltipWrapped>
</Button>
<PrimaryDropdown showThemeSection={false}>
<DotsThreeOutline
size={25}
color={theme.colors.thHighContrast.toString()}
/>
</PrimaryDropdown>
</HStack>
</>
)
}

Some files were not shown because too many files have changed in this diff Show more