Merge pull request #2327 from omnivore-app/feat/web-left-menu

Some menu and bulk selection UI
This commit is contained in:
Jackson Harper 2023-06-12 15:27:37 +08:00 committed by GitHub
commit daf822e398
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 946 additions and 537 deletions

View file

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

View file

@ -35,6 +35,9 @@ const DropdownTrigger = styled(Trigger, {
'&:hover': {
opacity: 0.7,
},
'&:focus': {
outline: 'none',
},
})
const StyledTriggerItem = styled(TriggerItem, {

View file

@ -31,6 +31,7 @@ export function LogoBox(): JSX.Element {
'@md': {
display: 'none',
},
lineHeight: '1',
}}
>
<OmnivoreNameLogo color={theme.colors.thHighContrast.toString()} />

View file

@ -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()
}

View file

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

View file

@ -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
}

View file

@ -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>
)
}

View file

@ -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>
</>
)
}

View file

@ -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>
</>
)
}

View file

@ -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"

View file

@ -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)

View file

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

View file

@ -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>
)}

View file

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

View file

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

View file

@ -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()) {

View file

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

View file

@ -18,7 +18,7 @@ function LoadedContent(): JSX.Element {
<VStack
alignment="center"
distribution="center"
css={{ backgroundColor: '$thBackground2' }}
css={{ backgroundColor: '$thLibraryBackground' }}
>
<HomeFeedContainer />
</VStack>

View file

@ -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>
)
}