mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #2327 from omnivore-app/feat/web-left-menu
Some menu and bulk selection UI
This commit is contained in:
commit
daf822e398
19 changed files with 946 additions and 537 deletions
|
|
@ -106,6 +106,7 @@ export const Button = styled('button', {
|
|||
borderRadius: '$3',
|
||||
px: '$3',
|
||||
py: '$2',
|
||||
gap: '5px',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid $grayBgSubtle',
|
||||
bg: '$grayBgSubtle',
|
||||
|
|
@ -113,6 +114,40 @@ export const Button = styled('button', {
|
|||
border: '1px solid $grayBorderHover',
|
||||
},
|
||||
},
|
||||
outline: {
|
||||
display: 'flex',
|
||||
borderRadius: '5px',
|
||||
alignItems: 'center',
|
||||
px: '15px',
|
||||
py: '5px',
|
||||
gap: '10px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
fontFamily: '$inter',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid $thBorderSubtle',
|
||||
bg: 'transparent',
|
||||
'&:hover': {
|
||||
border: '1px solid $grayBorderHover',
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
display: 'flex',
|
||||
borderRadius: '5px',
|
||||
alignItems: 'center',
|
||||
px: '15px',
|
||||
py: '5px',
|
||||
gap: '10px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
fontFamily: '$inter',
|
||||
cursor: 'pointer',
|
||||
bg: '$thBackground4',
|
||||
border: '1px solid $thBackground4',
|
||||
'&:hover': {
|
||||
border: '1px solid $grayBorderHover',
|
||||
},
|
||||
},
|
||||
modalOption: {
|
||||
style: 'ghost',
|
||||
height: '52px',
|
||||
|
|
@ -277,7 +312,7 @@ export const IconButton = styled(Button, {
|
|||
height: '28px',
|
||||
color: '#898989',
|
||||
border: 'unset',
|
||||
background: '$thBackground',
|
||||
background: 'transparent',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: 6,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ const DropdownTrigger = styled(Trigger, {
|
|||
'&:hover': {
|
||||
opacity: 0.7,
|
||||
},
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledTriggerItem = styled(TriggerItem, {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export function LogoBox(): JSX.Element {
|
|||
'@md': {
|
||||
display: 'none',
|
||||
},
|
||||
lineHeight: '1',
|
||||
}}
|
||||
>
|
||||
<OmnivoreNameLogo color={theme.colors.thHighContrast.toString()} />
|
||||
|
|
|
|||
|
|
@ -25,11 +25,6 @@ export function OmnivoreLogoBase(props: OmnivoreLogoBaseProps): JSX.Element {
|
|||
onClick={(event) => {
|
||||
const query = window.sessionStorage.getItem('q')
|
||||
if (query) {
|
||||
console.log(
|
||||
'going to: ',
|
||||
window.sessionStorage.getItem('q'),
|
||||
props.href
|
||||
)
|
||||
router.push(`/home?${query}`)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function ConfirmationModal(props: ConfirmationModalProps): JSX.Element {
|
|||
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
|
||||
<ModalOverlay />
|
||||
<ModalContent css={{ bg: '$grayBg', maxWidth: '20em', zIndex: '20' }}>
|
||||
<VStack alignment="center" distribution="center" css={{ p: '$2' }}>
|
||||
<VStack alignment="center" distribution="center" css={{ p: '15px' }}>
|
||||
{props.icon ? props.icon : null}
|
||||
{props.richMessage ? (
|
||||
props.richMessage
|
||||
|
|
|
|||
|
|
@ -19,5 +19,12 @@ export type LinkedItemCardProps = {
|
|||
item: LibraryItemNode
|
||||
layout: LayoutType
|
||||
viewer: UserBasicData
|
||||
|
||||
handleAction: (action: LinkedItemCardAction) => void
|
||||
|
||||
inMultiSelect: boolean
|
||||
isChecked: boolean
|
||||
setIsChecked: (itemId: string, set: boolean) => void
|
||||
|
||||
isHovered?: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,3 +146,18 @@ export function LibraryItemMetadata(
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
type CardCheckBoxProps = {
|
||||
isChecked: boolean
|
||||
handleChanged: () => void
|
||||
}
|
||||
|
||||
export function CardCheckbox(props: CardCheckBoxProps): JSX.Element {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.isChecked}
|
||||
onChange={props.handleChanged}
|
||||
></input>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ import type { LinkedItemCardProps } from './CardTypes'
|
|||
import { CoverImage } from '../../elements/CoverImage'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { DotsThreeVertical } from 'phosphor-react'
|
||||
import Link from 'next/link'
|
||||
import { CardMenu } from '../CardMenu'
|
||||
import {
|
||||
AuthorInfoStyle,
|
||||
CardCheckbox,
|
||||
DescriptionStyle,
|
||||
LibraryItemMetadata,
|
||||
MenuStyle,
|
||||
MetaStyle,
|
||||
siteName,
|
||||
timeAgo,
|
||||
TitleStyle,
|
||||
} from './LibraryCardStyles'
|
||||
import { sortedLabels } from '../../../lib/labelsSort'
|
||||
|
|
@ -52,12 +52,8 @@ export function ProgressBar(props: ProgressBarProps): JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
// Component
|
||||
export function LibraryGridCard(props: LinkedItemCardProps): JSX.Element {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const originText = siteName(props.item.originalArticleUrl, props.item.url)
|
||||
|
||||
return (
|
||||
<VStack
|
||||
|
|
@ -73,7 +69,7 @@ export function LibraryGridCard(props: LinkedItemCardProps): JSX.Element {
|
|||
borderStyle: 'solid',
|
||||
borderColor: '$thBorderColor',
|
||||
cursor: 'pointer',
|
||||
'@smDown': {
|
||||
'@media (max-width: 930px)': {
|
||||
m: '15px',
|
||||
width: 'calc(100% - 30px)',
|
||||
},
|
||||
|
|
@ -87,130 +83,154 @@ export function LibraryGridCard(props: LinkedItemCardProps): JSX.Element {
|
|||
setIsHovered(false)
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`${props.viewer.profile.username}/${props.item.slug}`}
|
||||
passHref
|
||||
>
|
||||
<a
|
||||
{props.inMultiSelect ? (
|
||||
<LibraryGridCardContent {...props} isHovered={isHovered} />
|
||||
) : (
|
||||
<Link
|
||||
href={`${props.viewer.profile.username}/${props.item.slug}`}
|
||||
style={{ textDecoration: 'unset', width: '100%', height: '100%' }}
|
||||
tabIndex={-1}
|
||||
passHref
|
||||
>
|
||||
<HStack
|
||||
css={{
|
||||
...MetaStyle,
|
||||
minHeight: '35px',
|
||||
}}
|
||||
distribution="start"
|
||||
<a
|
||||
href={`${props.viewer.profile.username}/${props.item.slug}`}
|
||||
style={{ textDecoration: 'unset', width: '100%', height: '100%' }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<LibraryItemMetadata item={props.item} />
|
||||
<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={
|
||||
<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,
|
||||
mt: '10px',
|
||||
}}
|
||||
>
|
||||
{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',
|
||||
}}
|
||||
>
|
||||
{sortedLabels(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.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'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</a>
|
||||
</Link>
|
||||
<LibraryGridCardContent {...props} isHovered={isHovered} />
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
const LibraryGridCardContent = (props: LinkedItemCardProps): JSX.Element => {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const originText = siteName(props.item.originalArticleUrl, props.item.url)
|
||||
|
||||
const handleCheckChanged = useCallback(() => {
|
||||
props.setIsChecked(props.item.id, !props.isChecked)
|
||||
}, [props.isChecked])
|
||||
|
||||
return (
|
||||
<>
|
||||
<HStack
|
||||
css={{
|
||||
...MetaStyle,
|
||||
minHeight: '35px',
|
||||
}}
|
||||
distribution="start"
|
||||
>
|
||||
<LibraryItemMetadata item={props.item} />
|
||||
{props.inMultiSelect ? (
|
||||
<SpanBox css={{ marginLeft: 'auto' }}>
|
||||
<CardCheckbox
|
||||
isChecked={props.isChecked}
|
||||
handleChanged={handleCheckChanged}
|
||||
/>
|
||||
</SpanBox>
|
||||
) : (
|
||||
<Box
|
||||
css={{
|
||||
...MenuStyle,
|
||||
visibility: props.isHovered || menuOpen ? 'unset' : 'hidden',
|
||||
'@media (hover: none)': {
|
||||
visibility: 'unset',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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,
|
||||
mt: '10px',
|
||||
}}
|
||||
>
|
||||
{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',
|
||||
}}
|
||||
>
|
||||
{sortedLabels(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.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'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,24 @@
|
|||
import { Box, VStack, HStack, SpanBox } from '../../elements/LayoutPrimitives'
|
||||
import { LabelChip } from '../../elements/LabelChip'
|
||||
import type { LinkedItemCardProps } from './CardTypes'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { DotsThree } from 'phosphor-react'
|
||||
import Link from 'next/link'
|
||||
import { CardMenu } from '../CardMenu'
|
||||
import {
|
||||
AuthorInfoStyle,
|
||||
CardCheckbox,
|
||||
LibraryItemMetadata,
|
||||
MenuStyle,
|
||||
MetaStyle,
|
||||
siteName,
|
||||
timeAgo,
|
||||
TitleStyle,
|
||||
} from './LibraryCardStyles'
|
||||
import { sortedLabels } from '../../../lib/labelsSort'
|
||||
import { LIBRARY_LEFT_MENU_WIDTH } from '../../templates/homeFeed/LibraryFilterMenu'
|
||||
|
||||
export function LibraryListCard(props: LinkedItemCardProps): JSX.Element {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const originText = siteName(props.item.originalArticleUrl, props.item.url)
|
||||
|
||||
return (
|
||||
<VStack
|
||||
|
|
@ -31,17 +29,18 @@ export function LibraryListCard(props: LinkedItemCardProps): JSX.Element {
|
|||
gap: '10px',
|
||||
border: '1px solid $grayBorder',
|
||||
borderBottom: 'none',
|
||||
width: '900px',
|
||||
|
||||
'@xxl': {
|
||||
width: '1200px',
|
||||
width: '100vw',
|
||||
'@media (min-width: 768px)': {
|
||||
width: `calc(100vw - ${LIBRARY_LEFT_MENU_WIDTH})`,
|
||||
},
|
||||
|
||||
'@xlgDown': {
|
||||
width: 'unset',
|
||||
borderRadius: 'unset',
|
||||
borderLeft: 'unset',
|
||||
borderRight: 'unset',
|
||||
'@media (min-width: 930px)': {
|
||||
width: '640px',
|
||||
},
|
||||
'@media (min-width: 1280px)': {
|
||||
width: '1000px',
|
||||
},
|
||||
'@media (min-width: 1600px)': {
|
||||
width: '1340px',
|
||||
},
|
||||
}}
|
||||
alignment="start"
|
||||
|
|
@ -53,77 +52,103 @@ export function LibraryListCard(props: LinkedItemCardProps): JSX.Element {
|
|||
setIsHovered(false)
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`${props.viewer.profile.username}/${props.item.slug}`}
|
||||
passHref
|
||||
>
|
||||
<a
|
||||
{props.inMultiSelect ? (
|
||||
<LibraryListCardContent {...props} isHovered={isHovered} />
|
||||
) : (
|
||||
<Link
|
||||
href={`${props.viewer.profile.username}/${props.item.slug}`}
|
||||
style={{ textDecoration: 'unset', width: '100%', height: '100%' }}
|
||||
tabIndex={-1}
|
||||
passHref
|
||||
>
|
||||
<HStack css={MetaStyle} distribution="start">
|
||||
<LibraryItemMetadata item={props.item} showProgress={true} />
|
||||
<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%' }}
|
||||
<a
|
||||
href={`${props.viewer.profile.username}/${props.item.slug}`}
|
||||
style={{ textDecoration: 'unset', width: '100%', height: '100%' }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{sortedLabels(props.item.labels).map(
|
||||
({ name, color }, index) => (
|
||||
<LabelChip key={index} text={name || ''} color={color} />
|
||||
)
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</a>
|
||||
</Link>
|
||||
<LibraryListCardContent {...props} isHovered={isHovered} />
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function LibraryListCardContent(
|
||||
props: LinkedItemCardProps
|
||||
): JSX.Element {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const originText = siteName(props.item.originalArticleUrl, props.item.url)
|
||||
|
||||
const handleCheckChanged = useCallback(() => {
|
||||
props.setIsChecked(props.item.id, !props.isChecked)
|
||||
}, [props.isChecked])
|
||||
|
||||
return (
|
||||
<>
|
||||
<HStack css={MetaStyle} distribution="start">
|
||||
<LibraryItemMetadata item={props.item} showProgress={true} />
|
||||
{props.inMultiSelect ? (
|
||||
<SpanBox css={{ marginLeft: 'auto' }}>
|
||||
<CardCheckbox
|
||||
isChecked={props.isChecked}
|
||||
handleChanged={handleCheckChanged}
|
||||
/>
|
||||
</SpanBox>
|
||||
) : (
|
||||
<Box
|
||||
css={{
|
||||
...MenuStyle,
|
||||
visibility: props.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',
|
||||
}}
|
||||
>
|
||||
{sortedLabels(props.item.labels).map(({ name, color }, index) => (
|
||||
<LabelChip key={index} text={name || ''} color={color} />
|
||||
))}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ type PrimaryDropdownProps = {
|
|||
|
||||
layout?: LayoutType
|
||||
updateLayout?: (layout: LayoutType) => void
|
||||
|
||||
showAddLinkModal?: () => void
|
||||
startSelectMultiple?: () => void
|
||||
}
|
||||
|
||||
export type HeaderDropdownAction =
|
||||
|
|
@ -153,6 +156,27 @@ export function PrimaryDropdown(props: PrimaryDropdownProps): JSX.Element {
|
|||
onSelect={() => headerDropdownActionHandler('navigate-to-labels')}
|
||||
title="Labels"
|
||||
/>
|
||||
{props.showAddLinkModal && (
|
||||
<>
|
||||
<DropdownSeparator />
|
||||
|
||||
<DropdownOption
|
||||
onSelect={() => props.showAddLinkModal && props.showAddLinkModal()}
|
||||
title="Add Link"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{props.startSelectMultiple && (
|
||||
<>
|
||||
<DropdownOption
|
||||
onSelect={() =>
|
||||
props.startSelectMultiple && props.startSelectMultiple()
|
||||
}
|
||||
title="Select Multiple"
|
||||
/>
|
||||
<DropdownSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-api')}
|
||||
title="API Keys"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import axios from 'axios'
|
||||
import { Action, createAction, useKBar, useRegisterActions } from 'kbar'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { useRouter } from 'next/router'
|
||||
|
|
@ -41,8 +40,11 @@ import { EditLibraryItemModal } from './EditItemModals'
|
|||
import { EmptyLibrary } from './EmptyLibrary'
|
||||
import { HighlightItemsLayout } from './HighlightsLayout'
|
||||
import { LibraryFilterMenu } from './LibraryFilterMenu'
|
||||
import { LibraryHeader } from './LibraryHeader'
|
||||
import { LibraryHeader, MultiSelectMode } from './LibraryHeader'
|
||||
import { UploadModal } from '../UploadModal'
|
||||
import { BulkAction } from '../../../lib/networking/mutations/bulkActionMutation'
|
||||
import { bulkActionMutation } from '../../../lib/networking/mutations/bulkActionMutation'
|
||||
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
|
||||
|
||||
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
|
||||
export type LibraryMode = 'reads' | 'highlights'
|
||||
|
|
@ -117,6 +119,7 @@ export function HomeFeedContainer(): JSX.Element {
|
|||
} else {
|
||||
setMode('reads')
|
||||
}
|
||||
setMultiSelectMode('off')
|
||||
}, [queryInputs])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -302,13 +305,21 @@ export function HomeFeedContainer(): JSX.Element {
|
|||
}
|
||||
break
|
||||
case 'archive':
|
||||
performActionOnItem('archive', item)
|
||||
if (multiSelectMode !== 'off') {
|
||||
performMultiSelectAction(BulkAction.ARCHIVE)
|
||||
} else {
|
||||
performActionOnItem('archive', item)
|
||||
}
|
||||
break
|
||||
case 'unarchive':
|
||||
performActionOnItem('unarchive', item)
|
||||
break
|
||||
case 'delete':
|
||||
performActionOnItem('delete', item)
|
||||
if (multiSelectMode !== 'off') {
|
||||
performMultiSelectAction(BulkAction.DELETE)
|
||||
} else {
|
||||
performActionOnItem('delete', item)
|
||||
}
|
||||
break
|
||||
case 'mark-read':
|
||||
performActionOnItem('mark-read', item)
|
||||
|
|
@ -331,6 +342,23 @@ export function HomeFeedContainer(): JSX.Element {
|
|||
return labelsTarget || linkToEdit || linkToRemove || linkToUnsubscribe
|
||||
}, [labelsTarget, linkToEdit, linkToRemove, linkToUnsubscribe])
|
||||
|
||||
const [checkedItems, setCheckedItems] = useState<string[]>([])
|
||||
const [multiSelectMode, setMultiSelectMode] = useState<MultiSelectMode>('off')
|
||||
|
||||
const selectActiveArticle = useCallback(() => {
|
||||
console.log('selecting article: ', activeItem)
|
||||
if (activeItem) {
|
||||
if (multiSelectMode === 'off') {
|
||||
console.log('setting ')
|
||||
setMultiSelectMode('some')
|
||||
}
|
||||
const itemId = activeItem.node.id
|
||||
const isChecked = itemIsChecked(itemId)
|
||||
console.log('setting is checked: ', isChecked, itemId)
|
||||
setIsChecked(itemId, !isChecked)
|
||||
}
|
||||
}, [activeItem, multiSelectMode, checkedItems])
|
||||
|
||||
useKeyboardShortcuts(
|
||||
libraryListCommands((action) => {
|
||||
const columnCount = (container: HTMLDivElement) => {
|
||||
|
|
@ -348,7 +376,16 @@ export function HomeFeedContainer(): JSX.Element {
|
|||
|
||||
switch (action) {
|
||||
case 'openArticle':
|
||||
handleCardAction('showDetail', activeItem)
|
||||
if (multiSelectMode !== 'off' && activeItem) {
|
||||
const itemId = activeItem.node.id
|
||||
const isChecked = itemIsChecked(itemId)
|
||||
setIsChecked(itemId, !isChecked)
|
||||
} else {
|
||||
handleCardAction('showDetail', activeItem)
|
||||
}
|
||||
break
|
||||
case 'selectArticle':
|
||||
selectActiveArticle()
|
||||
break
|
||||
case 'openOriginalArticle':
|
||||
handleCardAction('showOriginal', activeItem)
|
||||
|
|
@ -435,6 +472,14 @@ export function HomeFeedContainer(): JSX.Element {
|
|||
case 'sortAscending':
|
||||
setQueryInputs({ ...queryInputs, sortDescending: false })
|
||||
break
|
||||
case 'beginMultiSelect':
|
||||
if (multiSelectMode == 'off') {
|
||||
setMultiSelectMode('none')
|
||||
}
|
||||
break
|
||||
case 'endMultiSelect':
|
||||
setMultiSelectMode('off')
|
||||
break
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
@ -522,11 +567,105 @@ export function HomeFeedContainer(): JSX.Element {
|
|||
)
|
||||
useFetchMore(handleFetchMore)
|
||||
|
||||
const setIsChecked = useCallback(
|
||||
(itemId: string, set: boolean) => {
|
||||
if (set && checkedItems.indexOf(itemId) === -1) {
|
||||
checkedItems.push(itemId)
|
||||
setCheckedItems([...checkedItems])
|
||||
} else if (!set && checkedItems.indexOf(itemId) !== -1) {
|
||||
checkedItems.splice(checkedItems.indexOf(itemId), 1)
|
||||
setCheckedItems([...checkedItems])
|
||||
}
|
||||
},
|
||||
[checkedItems]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
switch (multiSelectMode) {
|
||||
case 'off':
|
||||
case 'none':
|
||||
setCheckedItems([])
|
||||
break
|
||||
case 'some':
|
||||
break
|
||||
case 'search':
|
||||
case 'visible':
|
||||
const allIds = (
|
||||
itemsPages?.flatMap((ad) => {
|
||||
return ad.search.edges
|
||||
}) || []
|
||||
).map((item) => item.node.id)
|
||||
setCheckedItems(allIds)
|
||||
break
|
||||
}
|
||||
}, [multiSelectMode])
|
||||
|
||||
const itemIsChecked = useCallback(
|
||||
(itemId: string) => {
|
||||
return checkedItems.indexOf(itemId) !== -1
|
||||
},
|
||||
[checkedItems]
|
||||
)
|
||||
|
||||
const performMultiSelectAction = useCallback(
|
||||
(action: BulkAction) => {
|
||||
if (multiSelectMode === 'off') {
|
||||
return
|
||||
}
|
||||
if (multiSelectMode !== 'search' && checkedItems.length < 1) {
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
'performing bulk action: ',
|
||||
action,
|
||||
'mode',
|
||||
multiSelectMode,
|
||||
checkedItems
|
||||
)
|
||||
;(async () => {
|
||||
const query =
|
||||
multiSelectMode === 'search'
|
||||
? queryInputs.searchQuery || 'in:inbox'
|
||||
: `includes:${checkedItems.join(',')}`
|
||||
const expectedCount =
|
||||
multiSelectMode === 'search'
|
||||
? itemsPages?.[0].search.pageInfo.totalCount || 0
|
||||
: checkedItems.length
|
||||
|
||||
try {
|
||||
const res = await bulkActionMutation(action, query, expectedCount)
|
||||
if (res) {
|
||||
switch (action) {
|
||||
case BulkAction.ARCHIVE:
|
||||
showSuccessToast('Items archived')
|
||||
break
|
||||
case BulkAction.DELETE:
|
||||
showSuccessToast('Items deleted')
|
||||
break
|
||||
}
|
||||
} else {
|
||||
showErrorToast('Error performing bulk action')
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorToast('Error performing bulk action')
|
||||
}
|
||||
mutate()
|
||||
})()
|
||||
setMultiSelectMode('off')
|
||||
},
|
||||
[itemsPages, multiSelectMode, checkedItems]
|
||||
)
|
||||
|
||||
return (
|
||||
<HomeFeedGrid
|
||||
items={libraryItems}
|
||||
actionHandler={handleCardAction}
|
||||
reloadItems={mutate}
|
||||
setIsChecked={setIsChecked}
|
||||
itemIsChecked={itemIsChecked}
|
||||
multiSelectMode={multiSelectMode}
|
||||
setMultiSelectMode={setMultiSelectMode}
|
||||
performMultiSelectAction={performMultiSelectAction}
|
||||
searchTerm={queryInputs.searchQuery}
|
||||
gridContainerRef={gridContainerRef}
|
||||
mode={mode}
|
||||
|
|
@ -573,6 +712,11 @@ export function HomeFeedContainer(): JSX.Element {
|
|||
setLinkToEdit={setLinkToEdit}
|
||||
linkToUnsubscribe={linkToUnsubscribe}
|
||||
setLinkToUnsubscribe={setLinkToUnsubscribe}
|
||||
numItemsSelected={
|
||||
multiSelectMode == 'search'
|
||||
? itemsPages?.[0].search.pageInfo.totalCount || 0
|
||||
: checkedItems.length
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -610,6 +754,14 @@ type HomeFeedContentProps = {
|
|||
action: LinkedItemCardAction,
|
||||
item: LibraryItem | undefined
|
||||
) => Promise<void>
|
||||
|
||||
multiSelectMode: MultiSelectMode
|
||||
setIsChecked: (itemId: string, set: boolean) => void
|
||||
itemIsChecked: (itemId: string) => boolean
|
||||
setMultiSelectMode: (mode: MultiSelectMode) => void
|
||||
numItemsSelected: number
|
||||
|
||||
performMultiSelectAction: (action: BulkAction) => void
|
||||
}
|
||||
|
||||
function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
|
||||
|
|
@ -641,11 +793,17 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
|
|||
updateLayout={updateLayout}
|
||||
searchTerm={props.searchTerm}
|
||||
applySearchQuery={(searchQuery: string) => {
|
||||
console.log('searching with searchQuery: ', searchQuery)
|
||||
props.applySearchQuery(searchQuery)
|
||||
}}
|
||||
allowSelectMultiple={props.mode !== 'highlights'}
|
||||
alwaysShowHeader={props.mode == 'highlights'}
|
||||
showFilterMenu={showFilterMenu}
|
||||
setShowFilterMenu={setShowFilterMenu}
|
||||
multiSelectMode={props.multiSelectMode}
|
||||
setMultiSelectMode={props.setMultiSelectMode}
|
||||
numItemsSelected={props.numItemsSelected}
|
||||
showAddLinkModal={() => props.setShowAddLinkModal(true)}
|
||||
performMultiSelectAction={props.performMultiSelectAction}
|
||||
/>
|
||||
<HStack css={{ width: '100%', height: '100%' }}>
|
||||
<LibraryFilterMenu
|
||||
|
|
@ -671,6 +829,8 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
|
|||
<LibraryItemsLayout
|
||||
viewer={viewerData?.me}
|
||||
layout={layout}
|
||||
inMultiSelect={props.multiSelectMode !== 'off'}
|
||||
isChecked={props.itemIsChecked}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -686,6 +846,10 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
|
|||
type LibraryItemsLayoutProps = {
|
||||
layout: LayoutType
|
||||
viewer?: UserBasicData
|
||||
inMultiSelect: boolean
|
||||
|
||||
isChecked: (itemId: string) => boolean
|
||||
setIsChecked: (itemId: string, set: boolean) => void
|
||||
} & HomeFeedContentProps
|
||||
|
||||
function LibraryItemsLayout(props: LibraryItemsLayoutProps): JSX.Element {
|
||||
|
|
@ -745,6 +909,8 @@ function LibraryItemsLayout(props: LibraryItemsLayoutProps): JSX.Element {
|
|||
items={props.items}
|
||||
layout={props.layout}
|
||||
viewer={props.viewer}
|
||||
isChecked={props.isChecked}
|
||||
setIsChecked={props.setIsChecked}
|
||||
gridContainerRef={props.gridContainerRef}
|
||||
setShowEditTitleModal={props.setShowEditTitleModal}
|
||||
setLinkToEdit={props.setLinkToEdit}
|
||||
|
|
@ -753,6 +919,7 @@ function LibraryItemsLayout(props: LibraryItemsLayoutProps): JSX.Element {
|
|||
setLinkToUnsubscribe={props.setLinkToUnsubscribe}
|
||||
setShowRemoveLinkConfirmation={setShowRemoveLinkConfirmation}
|
||||
actionHandler={props.actionHandler}
|
||||
inMultiSelect={props.inMultiSelect}
|
||||
/>
|
||||
)}
|
||||
<HStack
|
||||
|
|
@ -806,6 +973,10 @@ function LibraryItemsLayout(props: LibraryItemsLayoutProps): JSX.Element {
|
|||
item={props.linkToRemove?.node}
|
||||
viewer={props.viewer}
|
||||
layout="GRID_LAYOUT"
|
||||
inMultiSelect={false}
|
||||
isChecked={false}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
setIsChecked={() => {}}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
handleAction={() => {}}
|
||||
/>
|
||||
|
|
@ -873,6 +1044,10 @@ type LibraryItemsProps = {
|
|||
setLinkToUnsubscribe: (set: LibraryItem | undefined) => void
|
||||
setShowRemoveLinkConfirmation: (show: true) => void
|
||||
|
||||
inMultiSelect: boolean
|
||||
isChecked: (itemId: string) => boolean
|
||||
setIsChecked: (itemId: string, set: boolean) => void
|
||||
|
||||
actionHandler: (
|
||||
action: LinkedItemCardAction,
|
||||
item: LibraryItem | undefined
|
||||
|
|
@ -888,12 +1063,12 @@ function LibraryItems(props: LibraryItemsProps): JSX.Element {
|
|||
display: 'grid',
|
||||
width: '100%',
|
||||
gridAutoRows: 'auto',
|
||||
borderRadius: '8px',
|
||||
borderRadius: '5px',
|
||||
gridGap: props.layout == 'LIST_LAYOUT' ? '0' : '20px',
|
||||
marginTop: props.layout == 'LIST_LAYOUT' ? '21px' : '0',
|
||||
marginTop: '10px',
|
||||
marginBottom: '0px',
|
||||
paddingTop: props.layout == 'LIST_LAYOUT' ? '0' : '21px',
|
||||
paddingBottom: props.layout == 'LIST_LAYOUT' ? '0px' : '21px',
|
||||
paddingTop: '0',
|
||||
paddingBottom: '0px',
|
||||
overflow: 'hidden',
|
||||
'@xlgDown': {
|
||||
border: 'unset',
|
||||
|
|
@ -956,6 +1131,9 @@ function LibraryItems(props: LibraryItemsProps): JSX.Element {
|
|||
layout={props.layout}
|
||||
item={linkedItem.node}
|
||||
viewer={props.viewer}
|
||||
isChecked={props.isChecked(linkedItem.node.id)}
|
||||
setIsChecked={props.setIsChecked}
|
||||
inMultiSelect={props.inMultiSelect}
|
||||
handleAction={(action: LinkedItemCardAction) => {
|
||||
if (action === 'delete') {
|
||||
props.setShowRemoveLinkConfirmation(true)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Label } from '../../../lib/networking/fragments/labelFragment'
|
|||
import { theme } from '../../tokens/stitches.config'
|
||||
import { currentThemeName } from '../../../lib/themeUpdater'
|
||||
import { useRegisterActions } from 'kbar'
|
||||
import { HEADER_HEIGHT } from './HeaderSpacer'
|
||||
import { LogoBox } from '../../elements/LogoBox'
|
||||
|
||||
export const LIBRARY_LEFT_MENU_WIDTH = '233px'
|
||||
|
||||
|
|
@ -30,12 +30,11 @@ export function LibraryFilterMenu(props: LibraryFilterMenuProps): JSX.Element {
|
|||
<Box
|
||||
css={{
|
||||
left: '0px',
|
||||
top: HEADER_HEIGHT,
|
||||
top: '0px',
|
||||
position: 'fixed',
|
||||
bg: '$thBackground',
|
||||
bg: '$thLeftMenuBackground',
|
||||
height: '100%',
|
||||
width: LIBRARY_LEFT_MENU_WIDTH,
|
||||
borderRight: '1px solid $thBorderColor',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
'&::-webkit-scrollbar': {
|
||||
|
|
@ -48,13 +47,21 @@ export function LibraryFilterMenu(props: LibraryFilterMenuProps): JSX.Element {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
css={{
|
||||
width: '100%',
|
||||
px: '25px',
|
||||
pb: '25px',
|
||||
pt: '4.5px',
|
||||
lineHeight: '1',
|
||||
}}
|
||||
>
|
||||
<LogoBox />
|
||||
</Box>
|
||||
|
||||
<SavedSearches {...props} />
|
||||
<Subscriptions {...props} />
|
||||
<Labels {...props} />
|
||||
|
||||
<AddLinkButton
|
||||
showAddLinkModal={() => props.setShowAddLinkModal(true)}
|
||||
/>
|
||||
<Box css={{ height: '250px ' }} />
|
||||
</Box>
|
||||
{/* This spacer pushes library content to the right of
|
||||
|
|
@ -236,7 +243,7 @@ function MenuPanel(props: MenuPanelProps): JSX.Element {
|
|||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
lineHeight: '125%',
|
||||
color: '$thTextContrast',
|
||||
color: '$thLibraryMenuPrimary',
|
||||
pl: '10px',
|
||||
my: '20px',
|
||||
}}
|
||||
|
|
@ -324,11 +331,14 @@ function FilterButton(props: FilterButtonProps): JSX.Element {
|
|||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
height: '32px',
|
||||
backgroundColor: selected ? '#FFEA9F' : 'unset',
|
||||
|
||||
backgroundColor: selected ? '$thLibrarySelectionColor' : 'unset',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'regular',
|
||||
fontFamily: '$display',
|
||||
color: selected ? '#3D3D3D' : '$thTextSubtle',
|
||||
color: selected
|
||||
? '$thLibraryMenuSecondary'
|
||||
: '$thLibraryMenuUnselected',
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
|
|
@ -337,10 +347,14 @@ function FilterButton(props: FilterButtonProps): JSX.Element {
|
|||
whiteSpace: 'nowrap',
|
||||
alignItems: 'center',
|
||||
'&:hover': {
|
||||
backgroundColor: selected ? '#FFEA9F' : '$thBackground4',
|
||||
backgroundColor: selected
|
||||
? '$thLibrarySelectionColor'
|
||||
: '$thBackground4',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: selected ? '#FFEA9F' : '$thBackground4',
|
||||
backgroundColor: selected
|
||||
? '$thLibrarySelectionColor'
|
||||
: '$thBackground4',
|
||||
},
|
||||
}}
|
||||
onClick={(e) => {
|
||||
|
|
@ -434,66 +448,6 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
type AddLinkButtonProps = {
|
||||
showAddLinkModal: () => void
|
||||
}
|
||||
|
||||
function AddLinkButton(props: AddLinkButtonProps): JSX.Element {
|
||||
const currentTheme = currentThemeName()
|
||||
const isDark = currentTheme == 'Dark'
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack
|
||||
css={{
|
||||
position: 'fixed',
|
||||
bottom: '0px',
|
||||
|
||||
pl: '25px',
|
||||
height: '80px',
|
||||
bg: '$thBackground',
|
||||
width: LIBRARY_LEFT_MENU_WIDTH,
|
||||
borderTop: '1px solid $thBorderColor',
|
||||
borderRight: '1px solid $thBorderColor',
|
||||
|
||||
'@mdDown': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ViewAllButtonProps = {
|
||||
state: boolean
|
||||
setState: (state: boolean) => void
|
||||
|
|
|
|||
|
|
@ -1,18 +1,37 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Box, HStack, SpanBox, 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 {
|
||||
ArchiveBox,
|
||||
CaretDown,
|
||||
FunnelSimple,
|
||||
ListBullets,
|
||||
MagnifyingGlass,
|
||||
Prohibit,
|
||||
SquaresFour,
|
||||
TagSimple,
|
||||
TrashSimple,
|
||||
X,
|
||||
} from 'phosphor-react'
|
||||
import { LayoutType } from './HomeFeedContainer'
|
||||
import { PrimaryDropdown } from '../PrimaryDropdown'
|
||||
import { LogoBox } from '../../elements/LogoBox'
|
||||
import { OmnivoreSmallLogo } from '../../elements/images/OmnivoreNameLogo'
|
||||
import { HeaderSpacer, HEADER_HEIGHT } from './HeaderSpacer'
|
||||
import { LIBRARY_LEFT_MENU_WIDTH } from '../../templates/homeFeed/LibraryFilterMenu'
|
||||
import {
|
||||
ScrollOffsetChangeset,
|
||||
useScrollWatcher,
|
||||
} from '../../../lib/hooks/useScrollWatcher'
|
||||
import { CardCheckbox } from '../../patterns/LibraryCards/LibraryCardStyles'
|
||||
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
||||
import { BulkAction } from '../../../lib/networking/mutations/bulkActionMutation'
|
||||
import { ConfirmationModal } from '../../patterns/ConfirmationModal'
|
||||
|
||||
export type MultiSelectMode = 'off' | 'none' | 'some' | 'visible' | 'search'
|
||||
|
||||
type LibraryHeaderProps = {
|
||||
layout: LayoutType
|
||||
|
|
@ -21,11 +40,38 @@ type LibraryHeaderProps = {
|
|||
searchTerm: string | undefined
|
||||
applySearchQuery: (searchQuery: string) => void
|
||||
|
||||
alwaysShowHeader: boolean
|
||||
allowSelectMultiple: boolean
|
||||
|
||||
showFilterMenu: boolean
|
||||
setShowFilterMenu: (show: boolean) => void
|
||||
|
||||
showAddLinkModal: () => void
|
||||
|
||||
numItemsSelected: number
|
||||
multiSelectMode: MultiSelectMode
|
||||
setMultiSelectMode: (mode: MultiSelectMode) => void
|
||||
|
||||
performMultiSelectAction: (action: BulkAction) => void
|
||||
}
|
||||
|
||||
export function LibraryHeader(props: LibraryHeaderProps): JSX.Element {
|
||||
const [showBackground, setShowBackground] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.alwaysShowHeader) {
|
||||
setShowBackground(window.scrollY > 5 || props.multiSelectMode !== 'off')
|
||||
} else {
|
||||
setShowBackground(true)
|
||||
}
|
||||
}, [props.multiSelectMode, props.alwaysShowHeader])
|
||||
|
||||
useScrollWatcher((changeset: ScrollOffsetChangeset) => {
|
||||
if (!props.alwaysShowHeader) {
|
||||
setShowBackground(window.scrollY > 5 || props.multiSelectMode !== 'off')
|
||||
}
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack
|
||||
|
|
@ -33,14 +79,16 @@ export function LibraryHeader(props: LibraryHeaderProps): JSX.Element {
|
|||
distribution="start"
|
||||
css={{
|
||||
top: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
left: LIBRARY_LEFT_MENU_WIDTH,
|
||||
zIndex: 5,
|
||||
position: 'fixed',
|
||||
width: '100%',
|
||||
height: HEADER_HEIGHT,
|
||||
bg: '$thBackground',
|
||||
pt: '0px',
|
||||
borderBottom: '1px solid $thBorderColor',
|
||||
bg: showBackground ? '$thBackground' : 'transparent',
|
||||
'@mdDown': {
|
||||
left: '0px',
|
||||
right: '0',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* These will display/hide depending on breakpoints */}
|
||||
|
|
@ -59,7 +107,7 @@ function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element {
|
|||
return (
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
distribution="center"
|
||||
css={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
|
@ -68,11 +116,17 @@ function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<LogoBox />
|
||||
<SearchBox {...props} />
|
||||
<ControlButtonBox
|
||||
layout={props.layout}
|
||||
updateLayout={props.updateLayout}
|
||||
numItemsSelected={props.numItemsSelected}
|
||||
multiSelectMode={props.multiSelectMode}
|
||||
setMultiSelectMode={props.setMultiSelectMode}
|
||||
showAddLinkModal={props.showAddLinkModal}
|
||||
performMultiSelectAction={props.performMultiSelectAction}
|
||||
searchTerm={props.searchTerm}
|
||||
applySearchQuery={props.applySearchQuery}
|
||||
allowSelectMultiple={props.allowSelectMultiple}
|
||||
/>
|
||||
</HStack>
|
||||
)
|
||||
|
|
@ -109,11 +163,19 @@ function SmallHeaderLayout(props: LibraryHeaderProps): JSX.Element {
|
|||
</HStack>
|
||||
) : (
|
||||
<>
|
||||
<MenuHeaderButton {...props} />
|
||||
{props.multiSelectMode === 'off' && <MenuHeaderButton {...props} />}
|
||||
<ControlButtonBox
|
||||
layout={props.layout}
|
||||
updateLayout={props.updateLayout}
|
||||
setShowInlineSearch={setShowInlineSearch}
|
||||
numItemsSelected={props.numItemsSelected}
|
||||
multiSelectMode={props.multiSelectMode}
|
||||
setMultiSelectMode={props.setMultiSelectMode}
|
||||
showAddLinkModal={props.showAddLinkModal}
|
||||
performMultiSelectAction={props.performMultiSelectAction}
|
||||
searchTerm={props.searchTerm}
|
||||
applySearchQuery={props.applySearchQuery}
|
||||
allowSelectMultiple={props.allowSelectMultiple}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -177,6 +239,10 @@ export function SearchBox(props: SearchBoxProps): JSX.Element {
|
|||
const [focused, setFocused] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState(props.searchTerm ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(props.searchTerm ?? '')
|
||||
}, [props.searchTerm])
|
||||
|
||||
const border = props.compact
|
||||
? focused
|
||||
? '1px solid $omnivoreCtaYellow'
|
||||
|
|
@ -203,7 +269,7 @@ export function SearchBox(props: SearchBoxProps): JSX.Element {
|
|||
height: '38px',
|
||||
width: '100%',
|
||||
maxWidth: '521px',
|
||||
bg: props.compact ? 'white' : '$thBackground2',
|
||||
bg: '$thLibrarySearchbox',
|
||||
borderRadius: '6px',
|
||||
border: border,
|
||||
}}
|
||||
|
|
@ -317,55 +383,257 @@ type ControlButtonBoxProps = {
|
|||
layout: LayoutType
|
||||
updateLayout: (layout: LayoutType) => void
|
||||
setShowInlineSearch?: (show: boolean) => void
|
||||
|
||||
showAddLinkModal: () => void
|
||||
|
||||
allowSelectMultiple: boolean
|
||||
|
||||
numItemsSelected: number
|
||||
multiSelectMode: MultiSelectMode
|
||||
setMultiSelectMode: (mode: MultiSelectMode) => void
|
||||
|
||||
performMultiSelectAction: (action: BulkAction) => void
|
||||
|
||||
searchTerm: string | undefined
|
||||
applySearchQuery: (searchQuery: string) => void
|
||||
}
|
||||
|
||||
function MultiSelectControlButtonBox(
|
||||
props: ControlButtonBoxProps
|
||||
): JSX.Element {
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
|
||||
return (
|
||||
<HStack alignment="center" distribution="center" css={{ gap: '20px' }}>
|
||||
<Button
|
||||
style="outline"
|
||||
onClick={(e) => {
|
||||
props.performMultiSelectAction(BulkAction.ARCHIVE)
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<ArchiveBox
|
||||
width={20}
|
||||
height={20}
|
||||
color={theme.colors.thTextContrast2.toString()}
|
||||
/>
|
||||
<SpanBox css={{ '@lgDown': { display: 'none' } }}>Archive</SpanBox>
|
||||
</Button>
|
||||
{/* <Button
|
||||
style="outline"
|
||||
onClick={(e) => {
|
||||
props.performMultiSelectAction(BulkAction.ADD_LABELS)
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<TagSimple
|
||||
width={20}
|
||||
height={20}
|
||||
color={theme.colors.thTextContrast2.toString()}
|
||||
/>
|
||||
<SpanBox css={{ '@lgDown': { display: 'none' } }}>Label</SpanBox>
|
||||
</Button> */}
|
||||
<Button
|
||||
style="outline"
|
||||
onClick={(e) => {
|
||||
setShowConfirmDelete(true)
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<TrashSimple
|
||||
width={20}
|
||||
height={20}
|
||||
color={theme.colors.thTextContrast2.toString()}
|
||||
/>
|
||||
<SpanBox css={{ '@lgDown': { display: 'none' } }}>Delete</SpanBox>
|
||||
</Button>
|
||||
<Button
|
||||
style="cancel"
|
||||
onClick={(e) => {
|
||||
props.setMultiSelectMode('off')
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<Prohibit
|
||||
width={20}
|
||||
height={20}
|
||||
color={theme.colors.thTextContrast2.toString()}
|
||||
/>
|
||||
Cancel
|
||||
</Button>
|
||||
{showConfirmDelete && (
|
||||
<ConfirmationModal
|
||||
message={`You are about to delete ${props.numItemsSelected} items. All associated notes and highlights will be deleted.`}
|
||||
acceptButtonLabel={'Delete'}
|
||||
onAccept={() => {
|
||||
props.performMultiSelectAction(BulkAction.DELETE)
|
||||
}}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setShowConfirmDelete(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
type SearchControlButtonBoxProps = ControlButtonBoxProps
|
||||
|
||||
function SearchControlButtonBox(
|
||||
props: SearchControlButtonBoxProps
|
||||
): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SearchBox {...props} />
|
||||
<Button
|
||||
style="plainIcon"
|
||||
css={{ display: 'flex', marginLeft: 'auto' }}
|
||||
onClick={(e) => {
|
||||
props.updateLayout(
|
||||
props.layout == 'GRID_LAYOUT' ? 'LIST_LAYOUT' : 'GRID_LAYOUT'
|
||||
)
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
{props.layout == 'GRID_LAYOUT' ? (
|
||||
<ListBullets
|
||||
width={30}
|
||||
height={30}
|
||||
weight="light"
|
||||
color={'#898989'}
|
||||
/>
|
||||
) : (
|
||||
<SquaresFour
|
||||
width={30}
|
||||
height={30}
|
||||
weight="light"
|
||||
color={'#898989'}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<PrimaryDropdown
|
||||
showThemeSection={true}
|
||||
startSelectMultiple={
|
||||
props.allowSelectMultiple
|
||||
? () => {
|
||||
props.setMultiSelectMode('none')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showAddLinkModal={props.showAddLinkModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlButtonBox(props: ControlButtonBoxProps): JSX.Element {
|
||||
const [isChecked, setIsChecked] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.multiSelectMode === 'off' || props.multiSelectMode === 'none') {
|
||||
setIsChecked(false)
|
||||
}
|
||||
}, [props.multiSelectMode])
|
||||
|
||||
return (
|
||||
<>
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="end"
|
||||
distribution={props.multiSelectMode !== 'off' ? 'center' : 'start'}
|
||||
css={{
|
||||
marginLeft: 'auto',
|
||||
marginRight: '45px',
|
||||
width: '100px',
|
||||
height: '100%',
|
||||
gap: '20px',
|
||||
minWidth: '121px',
|
||||
gap: '10px',
|
||||
'@mdDown': {
|
||||
display: 'none',
|
||||
display: props.multiSelectMode !== 'off' ? 'flex' : 'none',
|
||||
},
|
||||
width: '95%',
|
||||
'@media (min-width: 930px)': {
|
||||
width: '640px',
|
||||
},
|
||||
'@media (min-width: 1280px)': {
|
||||
width: '1000px',
|
||||
},
|
||||
'@media (min-width: 1600px)': {
|
||||
width: '1340px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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} />
|
||||
{props.multiSelectMode !== 'off' && (
|
||||
<SpanBox
|
||||
css={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
gap: '2px',
|
||||
alignItems: 'center',
|
||||
'@mdDown': {
|
||||
mx: '20px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardCheckbox
|
||||
isChecked={isChecked}
|
||||
handleChanged={() => {
|
||||
const newValue = !isChecked
|
||||
props.setMultiSelectMode(newValue ? 'visible' : 'none')
|
||||
setIsChecked(newValue)
|
||||
}}
|
||||
/>
|
||||
<SpanBox css={{ pt: '2px' }}>
|
||||
<Dropdown
|
||||
triggerElement={
|
||||
<CaretDown
|
||||
size={15}
|
||||
color={theme.colors.thBorderSubtle.toString()}
|
||||
weight="fill"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
setIsChecked(true)
|
||||
props.setMultiSelectMode('visible')
|
||||
}}
|
||||
title="All"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
setIsChecked(true)
|
||||
props.setMultiSelectMode('search')
|
||||
}}
|
||||
title="All matching search"
|
||||
/>
|
||||
</Dropdown>
|
||||
</SpanBox>
|
||||
<SpanBox
|
||||
css={{
|
||||
paddingLeft: '5px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
fontFamily: '$inter',
|
||||
'@xlgDown': {
|
||||
paddingLeft: '5px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.numItemsSelected}{' '}
|
||||
<SpanBox
|
||||
css={{ '@media (max-width: 1280px)': { display: 'none' } }}
|
||||
>
|
||||
selected
|
||||
</SpanBox>
|
||||
</SpanBox>
|
||||
</SpanBox>
|
||||
)}
|
||||
{props.multiSelectMode !== 'off' ? (
|
||||
<>
|
||||
<MultiSelectControlButtonBox {...props} />
|
||||
<SpanBox css={{ flex: 1 }}></SpanBox>
|
||||
</>
|
||||
) : (
|
||||
<SearchControlButtonBox {...props} />
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{props.setShowInlineSearch && (
|
||||
{props.setShowInlineSearch && props.multiSelectMode === 'off' && (
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="end"
|
||||
|
|
@ -398,6 +666,9 @@ function ControlButtonBox(props: ControlButtonBoxProps): JSX.Element {
|
|||
showThemeSection={true}
|
||||
layout={props.layout}
|
||||
updateLayout={props.updateLayout}
|
||||
startSelectMultiple={() => {
|
||||
props.setMultiSelectMode('none')
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
|
|||
//utility
|
||||
overlay: 'rgba(63, 62, 60, 0.2)',
|
||||
|
||||
// New theme, special naming to keep things straigh
|
||||
// New theme, special naming to keep things straight
|
||||
// once all switch over, we will rename
|
||||
thBackground: '#FFFFFF',
|
||||
thBackground2: '#F3F3F3',
|
||||
|
|
@ -172,6 +172,13 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
|
|||
thBackground5: '#F5F5F5',
|
||||
thBackgroundActive: '#F9F9F9',
|
||||
thBackgroundContrast: '#FFFFFF',
|
||||
thLeftMenuBackground: '#FCFCFC',
|
||||
thLibraryBackground: '#F3F3F3',
|
||||
thLibrarySearchbox: '#FCFCFC',
|
||||
thLibraryMenuPrimary: '#3D3D3D',
|
||||
thLibraryMenuSecondary: '#3D3D3D',
|
||||
thLibraryMenuUnselected: '#898989',
|
||||
thLibrarySelectionColor: '#FFEA9F',
|
||||
|
||||
thTextContrast: '#1E1E1E',
|
||||
thTextContrast2: '#3D3D3D',
|
||||
|
|
@ -259,6 +266,13 @@ const darkThemeSpec = {
|
|||
thBackground5: '#3D3D3D',
|
||||
thBackgroundActive: '#2E2E2E',
|
||||
thBackgroundContrast: '#000000',
|
||||
thLeftMenuBackground: '#1D1D1D',
|
||||
thLibraryBackground: '#333333',
|
||||
thLibrarySearchbox: '#3D3D3D',
|
||||
thLibraryMenuPrimary: '#EBEBEB',
|
||||
thLibraryMenuSecondary: '#EBEBEB',
|
||||
thLibraryMenuUnselected: '#898989',
|
||||
thLibrarySelectionColor: '#3D3D3D',
|
||||
|
||||
thTextContrast: '#FFFFFF',
|
||||
thTextContrast2: '#EBEBEB',
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export function primaryCommands(
|
|||
|
||||
type LibraryListKeyboardAction =
|
||||
| 'openArticle'
|
||||
| 'selectArticle'
|
||||
| 'openOriginalArticle'
|
||||
| 'moveFocusToNextListItem'
|
||||
| 'moveFocusToPreviousListItem'
|
||||
|
|
@ -88,6 +89,8 @@ type LibraryListKeyboardAction =
|
|||
| 'shareItem'
|
||||
| 'showAddLinkModal'
|
||||
| 'showEditLabelsModal'
|
||||
| 'beginMultiSelect'
|
||||
| 'endMultiSelect'
|
||||
|
||||
export function libraryListCommands(
|
||||
actionHandler: (action: LibraryListKeyboardAction) => void
|
||||
|
|
@ -99,6 +102,14 @@ export function libraryListCommands(
|
|||
shortcutKeyDescription: 'enter/return',
|
||||
callback: () => actionHandler('openArticle'),
|
||||
},
|
||||
{
|
||||
shortcutKeys: ['x'],
|
||||
actionDescription: 'Select article',
|
||||
shortcutKeyDescription: 'x',
|
||||
callback: () => {
|
||||
actionHandler('selectArticle')
|
||||
},
|
||||
},
|
||||
{
|
||||
shortcutKeys: ['o'],
|
||||
actionDescription: 'Open original article',
|
||||
|
|
@ -123,6 +134,18 @@ export function libraryListCommands(
|
|||
shortcutKeyDescription: 'k or left arrow',
|
||||
callback: () => actionHandler('moveFocusToPreviousListItem'),
|
||||
},
|
||||
{
|
||||
shortcutKeys: ['m', 's'],
|
||||
actionDescription: 'Begin multi select',
|
||||
shortcutKeyDescription: 'm then s',
|
||||
callback: () => actionHandler('beginMultiSelect'),
|
||||
},
|
||||
{
|
||||
shortcutKeys: ['escape'],
|
||||
actionDescription: 'End multi select',
|
||||
shortcutKeyDescription: 'Escape',
|
||||
callback: () => actionHandler('endMultiSelect'),
|
||||
},
|
||||
// {
|
||||
// shortcutKeys: ['e'],
|
||||
// actionDescription: 'Archive item',
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ export const useKeyboardShortcuts = (commands: KeyboardCommand[]): void => {
|
|||
const { target } = keydownEvent
|
||||
if (!keydownEvent.key) return
|
||||
const key = keydownEvent.key.toLowerCase()
|
||||
|
||||
if (keys[key] === undefined) return
|
||||
if (keys[key] === false) {
|
||||
if (key === 'k' && metaPressed()) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { gqlFetcher } from '../networkHelpers'
|
|||
export enum BulkAction {
|
||||
ARCHIVE = 'ARCHIVE',
|
||||
DELETE = 'DELETE',
|
||||
ADD_LABELS = 'ADD_LABELS',
|
||||
}
|
||||
|
||||
type BulkActionResponseData = {
|
||||
|
|
@ -15,24 +16,40 @@ type BulkActionResponse = {
|
|||
bulkAction?: BulkActionResponseData
|
||||
}
|
||||
|
||||
export async function bulkActionMutation(action: BulkAction): Promise<boolean> {
|
||||
export async function bulkActionMutation(
|
||||
action: BulkAction,
|
||||
query: string,
|
||||
expectedCount: number
|
||||
): Promise<boolean> {
|
||||
const mutation = gql`
|
||||
mutation {
|
||||
bulkAction (action: ${action}) {
|
||||
... on BulkActionSuccess {
|
||||
success
|
||||
}
|
||||
... on BulkActionError {
|
||||
errorCodes
|
||||
mutation BulkAction(
|
||||
$action: BulkActionType!
|
||||
$query: String!
|
||||
$expectedCount: Int
|
||||
) {
|
||||
bulkAction(
|
||||
query: $query
|
||||
action: $action
|
||||
expectedCount: $expectedCount
|
||||
) {
|
||||
... on BulkActionSuccess {
|
||||
success
|
||||
}
|
||||
... on BulkActionError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
console.log('bulkActionbulkActionMutation', mutation)
|
||||
|
||||
try {
|
||||
const response = await gqlFetcher(mutation, { action })
|
||||
const response = await gqlFetcher(mutation, {
|
||||
action,
|
||||
query,
|
||||
expectedCount,
|
||||
})
|
||||
console.log('response', response)
|
||||
const data = response as BulkActionResponse | undefined
|
||||
return data?.bulkAction?.success ?? false
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ function LoadedContent(): JSX.Element {
|
|||
<VStack
|
||||
alignment="center"
|
||||
distribution="center"
|
||||
css={{ backgroundColor: '$thBackground2' }}
|
||||
css={{ backgroundColor: '$thLibraryBackground' }}
|
||||
>
|
||||
<HomeFeedContainer />
|
||||
</VStack>
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import { applyStoredTheme } from '../../lib/themeUpdater'
|
||||
|
||||
import { VStack } from '../../components/elements/LayoutPrimitives'
|
||||
|
||||
import { StyledText } from '../../components/elements/StyledText'
|
||||
import { ProfileLayout } from '../../components/templates/ProfileLayout'
|
||||
import {
|
||||
BulkAction,
|
||||
bulkActionMutation,
|
||||
} from '../../lib/networking/mutations/bulkActionMutation'
|
||||
import { Button } from '../../components/elements/Button'
|
||||
import { theme } from '../../components/tokens/stitches.config'
|
||||
import { ConfirmationModal } from '../../components/patterns/ConfirmationModal'
|
||||
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
type RunningState = 'none' | 'confirming' | 'running' | 'completed'
|
||||
|
||||
export default function BulkPerformer(): JSX.Element {
|
||||
const router = useRouter()
|
||||
|
||||
applyStoredTheme(false)
|
||||
|
||||
const [action, setAction] = useState<BulkAction | undefined>()
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>()
|
||||
const [runningState, setRunningState] = useState<RunningState>('none')
|
||||
|
||||
const performAction = useCallback(() => {
|
||||
;(async () => {
|
||||
console.log('performing action: ', action)
|
||||
if (!action) {
|
||||
showErrorToast('Unable to run action, no action set.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const success = await bulkActionMutation(action)
|
||||
if (!success) {
|
||||
throw 'Success not returned'
|
||||
}
|
||||
showSuccessToast('Bulk action is being performed.')
|
||||
setRunningState('completed')
|
||||
} catch (err) {
|
||||
showErrorToast('Error performing bulk action.')
|
||||
}
|
||||
})()
|
||||
}, [action])
|
||||
|
||||
return (
|
||||
<ProfileLayout logoDestination="/home">
|
||||
<VStack
|
||||
alignment="start"
|
||||
css={{
|
||||
padding: '16px',
|
||||
background: 'white',
|
||||
minWidth: '340px',
|
||||
width: '70vw',
|
||||
maxWidth: '576px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #3D3D3D',
|
||||
boxShadow: '#B1B1B1 9px 9px 9px -9px',
|
||||
}}
|
||||
>
|
||||
<StyledText
|
||||
style="modalHeadline"
|
||||
css={{
|
||||
color: theme.colors.omnivoreGray.toString(),
|
||||
}}
|
||||
>
|
||||
Perform a Bulk Action
|
||||
</StyledText>
|
||||
<StyledText
|
||||
style="caption"
|
||||
css={{ pt: '10px', color: theme.colors.omnivoreGray.toString() }}
|
||||
>
|
||||
Use this tool to perform a bulk operation on all the items in your
|
||||
library.<br></br>
|
||||
<a href="https://docs.omnivore.app/using/">More info</a>
|
||||
</StyledText>
|
||||
<StyledText
|
||||
style="caption"
|
||||
css={{ pt: '0px', color: theme.colors.omnivoreGray.toString() }}
|
||||
>
|
||||
<b>Note:</b> This operation can not be undone.
|
||||
</StyledText>
|
||||
<VStack css={{ pt: '36px', width: '100%' }}>
|
||||
{runningState == 'completed' ? (
|
||||
<StyledText
|
||||
style="caption"
|
||||
css={{
|
||||
pt: '10px',
|
||||
pb: '20px',
|
||||
color: theme.colors.omnivoreGray.toString(),
|
||||
}}
|
||||
>
|
||||
Your bulk action has started. Please note that it can take some
|
||||
time for these actions to complete. During this time, we recommend
|
||||
not modifying your library as new items could be updated by the
|
||||
action.
|
||||
</StyledText>
|
||||
) : (
|
||||
<>
|
||||
<VStack css={{ width: '100%', gap: '15px' }}>
|
||||
<select
|
||||
disabled={runningState == 'running'}
|
||||
onChange={(event) => {
|
||||
const updatedAction: BulkAction =
|
||||
BulkAction[
|
||||
event.currentTarget.value as keyof typeof BulkAction
|
||||
]
|
||||
setAction(updatedAction)
|
||||
}}
|
||||
style={{
|
||||
padding: '8px',
|
||||
height: '38px',
|
||||
borderRadius: '6px',
|
||||
minWidth: '196px',
|
||||
color: theme.colors.omnivoreGray.toString(),
|
||||
}}
|
||||
>
|
||||
<option value="none">Choose bulk action</option>
|
||||
<option value="ARCHIVE">Archive All</option>
|
||||
{router.isReady && router.query['allow_delete'] && (
|
||||
<option value="DELETE">Delete All</option>
|
||||
)}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
if (!action) {
|
||||
alert('No action selected')
|
||||
return
|
||||
}
|
||||
setRunningState('confirming')
|
||||
}}
|
||||
style="ctaDarkYellow"
|
||||
>
|
||||
Perform Action
|
||||
</Button>
|
||||
</VStack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{runningState == 'confirming' && (
|
||||
<ConfirmationModal
|
||||
message={`Are you sure you want to ${action} your entire library? This operation can not be undone.`}
|
||||
onAccept={performAction}
|
||||
onOpenChange={() => setRunningState('none')}
|
||||
/>
|
||||
)}
|
||||
{runningState == 'completed' && (
|
||||
<VStack css={{ width: '100%' }} alignment="center">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
window.location.href = '/home'
|
||||
e.preventDefault()
|
||||
}}
|
||||
style="ctaDarkYellow"
|
||||
>
|
||||
Return to Library
|
||||
</Button>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<StyledText style="error">{errorMessage}</StyledText>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</ProfileLayout>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue