diff --git a/packages/web/components/elements/Button.tsx b/packages/web/components/elements/Button.tsx index 313f5fff5..e405a5995 100644 --- a/packages/web/components/elements/Button.tsx +++ b/packages/web/components/elements/Button.tsx @@ -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, }, diff --git a/packages/web/components/elements/DropdownElements.tsx b/packages/web/components/elements/DropdownElements.tsx index b14f3f9a3..ed8528544 100644 --- a/packages/web/components/elements/DropdownElements.tsx +++ b/packages/web/components/elements/DropdownElements.tsx @@ -35,6 +35,9 @@ const DropdownTrigger = styled(Trigger, { '&:hover': { opacity: 0.7, }, + '&:focus': { + outline: 'none', + }, }) const StyledTriggerItem = styled(TriggerItem, { diff --git a/packages/web/components/elements/LogoBox.tsx b/packages/web/components/elements/LogoBox.tsx index 6ea738fe1..d7865ac81 100644 --- a/packages/web/components/elements/LogoBox.tsx +++ b/packages/web/components/elements/LogoBox.tsx @@ -31,6 +31,7 @@ export function LogoBox(): JSX.Element { '@md': { display: 'none', }, + lineHeight: '1', }} > diff --git a/packages/web/components/elements/images/OmnivoreLogoBase.tsx b/packages/web/components/elements/images/OmnivoreLogoBase.tsx index 9b0c7215d..ecbce303e 100644 --- a/packages/web/components/elements/images/OmnivoreLogoBase.tsx +++ b/packages/web/components/elements/images/OmnivoreLogoBase.tsx @@ -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() } diff --git a/packages/web/components/patterns/ConfirmationModal.tsx b/packages/web/components/patterns/ConfirmationModal.tsx index 7ff120c77..f38ed3dc9 100644 --- a/packages/web/components/patterns/ConfirmationModal.tsx +++ b/packages/web/components/patterns/ConfirmationModal.tsx @@ -22,7 +22,7 @@ export function ConfirmationModal(props: ConfirmationModalProps): JSX.Element { - + {props.icon ? props.icon : null} {props.richMessage ? ( props.richMessage diff --git a/packages/web/components/patterns/LibraryCards/CardTypes.tsx b/packages/web/components/patterns/LibraryCards/CardTypes.tsx index cf19502e1..2833d724d 100644 --- a/packages/web/components/patterns/LibraryCards/CardTypes.tsx +++ b/packages/web/components/patterns/LibraryCards/CardTypes.tsx @@ -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 } diff --git a/packages/web/components/patterns/LibraryCards/LibraryCardStyles.tsx b/packages/web/components/patterns/LibraryCards/LibraryCardStyles.tsx index b224994d7..1775c6143 100644 --- a/packages/web/components/patterns/LibraryCards/LibraryCardStyles.tsx +++ b/packages/web/components/patterns/LibraryCards/LibraryCardStyles.tsx @@ -146,3 +146,18 @@ export function LibraryItemMetadata( ) } + +type CardCheckBoxProps = { + isChecked: boolean + handleChanged: () => void +} + +export function CardCheckbox(props: CardCheckBoxProps): JSX.Element { + return ( + + ) +} diff --git a/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx b/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx index 16c1bbef3..66bf0b96b 100644 --- a/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx +++ b/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx @@ -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 ( - - + ) : ( + - - - - setMenuOpen(open)} - actionHandler={props.handleAction} - triggerElement={ - - } - /> - - - - - {props.item.title} - - {props.item.description} - - {props.item.author} - {props.item.author && originText && ' | '} - - {originText} - - - - - - - - - {sortedLabels(props.item.labels).map( - ({ name, color }, index) => ( - - ) - )} - - - {props.item.image && ( - { - ;(e.target as HTMLElement).style.display = 'none' - }} - /> - )} - - - - - + + + + )} ) } + +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 ( + <> + + + {props.inMultiSelect ? ( + + + + ) : ( + + setMenuOpen(open)} + actionHandler={props.handleAction} + triggerElement={ + + } + /> + + )} + + + + {props.item.title} + + {props.item.description} + + {props.item.author} + {props.item.author && originText && ' | '} + {originText} + + + + + + + + {sortedLabels(props.item.labels).map(({ name, color }, index) => ( + + ))} + + + {props.item.image && ( + { + ;(e.target as HTMLElement).style.display = 'none' + }} + /> + )} + + + + + ) +} diff --git a/packages/web/components/patterns/LibraryCards/LibraryListCard.tsx b/packages/web/components/patterns/LibraryCards/LibraryListCard.tsx index 4571449be..2ee000a48 100644 --- a/packages/web/components/patterns/LibraryCards/LibraryListCard.tsx +++ b/packages/web/components/patterns/LibraryCards/LibraryListCard.tsx @@ -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 ( - - + ) : ( + - - - - setMenuOpen(open)} - actionHandler={props.handleAction} - triggerElement={ - - } - /> - - - - {props.item.title} - - {props.item.author} - {props.item.author && originText && ' | '} - - {originText} - - - - - - {sortedLabels(props.item.labels).map( - ({ name, color }, index) => ( - - ) - )} - - - - - + + + + )} ) } + +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 ( + <> + + + {props.inMultiSelect ? ( + + + + ) : ( + + setMenuOpen(open)} + actionHandler={props.handleAction} + triggerElement={ + + } + /> + + )} + + + {props.item.title} + + {props.item.author} + {props.item.author && originText && ' | '} + {originText} + + + + + {sortedLabels(props.item.labels).map(({ name, color }, index) => ( + + ))} + + + + + ) +} diff --git a/packages/web/components/templates/PrimaryDropdown.tsx b/packages/web/components/templates/PrimaryDropdown.tsx index 5bddf16d4..f7c3acd56 100644 --- a/packages/web/components/templates/PrimaryDropdown.tsx +++ b/packages/web/components/templates/PrimaryDropdown.tsx @@ -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 && ( + <> + + + props.showAddLinkModal && props.showAddLinkModal()} + title="Add Link" + /> + + )} + {props.startSelectMultiple && ( + <> + + props.startSelectMultiple && props.startSelectMultiple() + } + title="Select Multiple" + /> + + + )} headerDropdownActionHandler('navigate-to-api')} title="API Keys" diff --git a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx index e2be6936b..6a0ee38f3 100644 --- a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx +++ b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx @@ -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([]) + const [multiSelectMode, setMultiSelectMode] = useState('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 ( ) } @@ -610,6 +754,14 @@ type HomeFeedContentProps = { action: LinkedItemCardAction, item: LibraryItem | undefined ) => Promise + + 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} /> )} @@ -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} /> )} {}} // 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) diff --git a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx index 93e08471e..3cc715600 100644 --- a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx +++ b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx @@ -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 { + + + + - - props.setShowAddLinkModal(true)} - /> {/* 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 ( - <> - - - - - ) -} - type ViewAllButtonProps = { state: boolean setState: (state: boolean) => void diff --git a/packages/web/components/templates/homeFeed/LibraryHeader.tsx b/packages/web/components/templates/homeFeed/LibraryHeader.tsx index 0bb757279..56e7e9bd2 100644 --- a/packages/web/components/templates/homeFeed/LibraryHeader.tsx +++ b/packages/web/components/templates/homeFeed/LibraryHeader.tsx @@ -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 ( <> {/* These will display/hide depending on breakpoints */} @@ -59,7 +107,7 @@ function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element { return ( - - ) @@ -109,11 +163,19 @@ function SmallHeaderLayout(props: LibraryHeaderProps): JSX.Element { ) : ( <> - + {props.multiSelectMode === 'off' && } )} @@ -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 ( + + + {/* */} + + + {showConfirmDelete && ( + { + props.performMultiSelectAction(BulkAction.DELETE) + }} + onOpenChange={(open: boolean) => { + setShowConfirmDelete(false) + }} + /> + )} + + ) +} + +type SearchControlButtonBoxProps = ControlButtonBoxProps + +function SearchControlButtonBox( + props: SearchControlButtonBoxProps +): JSX.Element { + return ( + <> + + + { + 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 ( <> - - - - + {props.multiSelectMode !== 'off' && ( + + { + const newValue = !isChecked + props.setMultiSelectMode(newValue ? 'visible' : 'none') + setIsChecked(newValue) + }} + /> + + + } + > + { + setIsChecked(true) + props.setMultiSelectMode('visible') + }} + title="All" + /> + { + setIsChecked(true) + props.setMultiSelectMode('search') + }} + title="All matching search" + /> + + + + {props.numItemsSelected}{' '} + + selected + + + + )} + {props.multiSelectMode !== 'off' ? ( + <> + + + + ) : ( + + )} - {props.setShowInlineSearch && ( + {props.setShowInlineSearch && props.multiSelectMode === 'off' && ( { + props.setMultiSelectMode('none') + }} /> )} diff --git a/packages/web/components/tokens/stitches.config.ts b/packages/web/components/tokens/stitches.config.ts index 1aa21caca..d37381370 100644 --- a/packages/web/components/tokens/stitches.config.ts +++ b/packages/web/components/tokens/stitches.config.ts @@ -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', diff --git a/packages/web/lib/keyboardShortcuts/navigationShortcuts.ts b/packages/web/lib/keyboardShortcuts/navigationShortcuts.ts index 2c4bd0bab..9b216f21b 100644 --- a/packages/web/lib/keyboardShortcuts/navigationShortcuts.ts +++ b/packages/web/lib/keyboardShortcuts/navigationShortcuts.ts @@ -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', diff --git a/packages/web/lib/keyboardShortcuts/useKeyboardShortcuts.ts b/packages/web/lib/keyboardShortcuts/useKeyboardShortcuts.ts index 35af88f33..1ac8dfa67 100644 --- a/packages/web/lib/keyboardShortcuts/useKeyboardShortcuts.ts +++ b/packages/web/lib/keyboardShortcuts/useKeyboardShortcuts.ts @@ -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()) { diff --git a/packages/web/lib/networking/mutations/bulkActionMutation.ts b/packages/web/lib/networking/mutations/bulkActionMutation.ts index 935e5a8e6..630645174 100644 --- a/packages/web/lib/networking/mutations/bulkActionMutation.ts +++ b/packages/web/lib/networking/mutations/bulkActionMutation.ts @@ -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 { +export async function bulkActionMutation( + action: BulkAction, + query: string, + expectedCount: number +): Promise { 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 diff --git a/packages/web/pages/home.tsx b/packages/web/pages/home.tsx index 714abbeaa..612d28d97 100644 --- a/packages/web/pages/home.tsx +++ b/packages/web/pages/home.tsx @@ -18,7 +18,7 @@ function LoadedContent(): JSX.Element { diff --git a/packages/web/pages/tools/bulk.tsx b/packages/web/pages/tools/bulk.tsx deleted file mode 100644 index 97bea8282..000000000 --- a/packages/web/pages/tools/bulk.tsx +++ /dev/null @@ -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() - const [errorMessage, setErrorMessage] = useState() - const [runningState, setRunningState] = useState('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 ( - - - - Perform a Bulk Action - - - Use this tool to perform a bulk operation on all the items in your - library.

- More info -
- - Note: This operation can not be undone. - - - {runningState == 'completed' ? ( - - 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. - - ) : ( - <> - - - - - - - )} - - {runningState == 'confirming' && ( - setRunningState('none')} - /> - )} - {runningState == 'completed' && ( - - - - )} - - {errorMessage && ( - {errorMessage} - )} - -
-
- ) -}