diff --git a/packages/api/src/jobs/rss/refreshFeed.ts b/packages/api/src/jobs/rss/refreshFeed.ts index 8cef84a32..121331038 100644 --- a/packages/api/src/jobs/rss/refreshFeed.ts +++ b/packages/api/src/jobs/rss/refreshFeed.ts @@ -532,6 +532,7 @@ const processSubscription = async ( if (itemCount == 100) { logger.info(`Max limit reached for feed ${feedUrl}`) } + itemCount = itemCount + 1 continue } diff --git a/packages/web/components/elements/Avatar.tsx b/packages/web/components/elements/Avatar.tsx index 447dbb4b2..34656f5d5 100644 --- a/packages/web/components/elements/Avatar.tsx +++ b/packages/web/components/elements/Avatar.tsx @@ -40,7 +40,7 @@ const StyledFallback = styled(Fallback, { justifyContent: 'center', fontSize: '15px', fontWeight: 600, - fontFamily: 'Inter', + fontFamily: '$inter', color: '$avatarFont', backgroundColor: '$avatarBg', }) diff --git a/packages/web/components/elements/AvatarDropdown.tsx b/packages/web/components/elements/AvatarDropdown.tsx index ec24d0a1f..c021afa11 100644 --- a/packages/web/components/elements/AvatarDropdown.tsx +++ b/packages/web/components/elements/AvatarDropdown.tsx @@ -8,7 +8,7 @@ type AvatarDropdownProps = { export function AvatarDropdown(props: AvatarDropdownProps): JSX.Element { return ( - + ) } diff --git a/packages/web/components/elements/Button.tsx b/packages/web/components/elements/Button.tsx index eea66f75a..54e399801 100644 --- a/packages/web/components/elements/Button.tsx +++ b/packages/web/components/elements/Button.tsx @@ -18,6 +18,22 @@ export const Button = styled('button', { border: '1px solid $grayBorderHover', }, }, + ctaBlue: { + borderRadius: '5px', + px: '20px', + py: '8px', + fontSize: '14px', + fontWeight: '500', + cursor: 'pointer', + border: '1px solid $yellow3', + bg: '$ctaBlue', + color: 'white', + '&:hover': { + opacity: '0.6', + border: '0px solid $ctaBlue', + }, + }, + ctaDarkYellow: { border: '1px solid transparent', fontSize: '14px', @@ -180,11 +196,11 @@ export const Button = styled('button', { py: '5px', font: '$inter', fontSize: '12px', - fontWeight: '700', + fontWeight: '500', whiteSpace: 'nowrap', color: '$thLibraryMenuPrimary', border: '1px solid $thLeftMenuBackground', - backgroundColor: '$thLeftMenuBackground', + bg: '$thBackgroundActive', '&:hover': { bg: '$thBackgroundActive', border: '1px solid $thBackgroundActive', @@ -195,12 +211,12 @@ export const Button = styled('button', { borderRadius: '15px', px: '12px', py: '5px', + bg: 'transparent', font: '$inter', fontSize: '12px', - fontWeight: 'medium', + fontWeight: '500', whiteSpace: 'nowrap', border: '1px solid $thBackground4', - backgroundColor: '$thBackground4', '&:hover': { bg: '$thBackgroundActive', border: '1px solid $thBackgroundActive', @@ -215,6 +231,36 @@ export const Button = styled('button', { color: '$thLibraryMenuUnselected', cursor: 'pointer', }, + tab: { + px: '15px', + py: '6px', + border: 'none', + bg: 'transparent', + fontSize: '12px', + fontWeight: '500', + fontFamily: '$inter', + color: '$tabTextUnselected', + cursor: 'pointer', + borderRadius: '5px', + '&:hover': { + color: '$thTextContrast', + }, + }, + tabSelected: { + px: '15px', + py: '6px', + border: 'none', + bg: '#6A6968', + fontSize: '12px', + fontWeight: '500', + fontFamily: '$inter', + color: 'white', + cursor: 'pointer', + borderRadius: '5px', + // '&:hover': { + // color: '$thTextContrast', + // }, + }, squareIcon: { mx: '$1', display: 'flex', diff --git a/packages/web/components/elements/CloseButton.tsx b/packages/web/components/elements/CloseButton.tsx index aaf31a852..d4468c629 100644 --- a/packages/web/components/elements/CloseButton.tsx +++ b/packages/web/components/elements/CloseButton.tsx @@ -14,8 +14,8 @@ export function CloseButton(props: CloseButtonProps): JSX.Element { {triggerElement} diff --git a/packages/web/components/elements/SplitButton.tsx b/packages/web/components/elements/SplitButton.tsx index ce6df2b67..8add1554a 100644 --- a/packages/web/components/elements/SplitButton.tsx +++ b/packages/web/components/elements/SplitButton.tsx @@ -2,13 +2,14 @@ import { ReactNode, useEffect, useMemo, useRef } from 'react' import { styled } from '../tokens/stitches.config' import { Box, HStack, VStack } from './LayoutPrimitives' import { Button } from './Button' -import { DropdownMenu } from '@radix-ui/react-dropdown-menu' -import { ArrowDown } from 'phosphor-react' import { Dropdown, DropdownOption } from './DropdownElements' import { CaretDownIcon } from './icons/CaretDownIcon' +type ShowLinkMode = 'none' | 'link' | 'pdf' + type SplitButtonProps = { title: string + setShowLinkMode: (mode: ShowLinkMode) => void } const CaretButton = (): JSX.Element => { @@ -18,36 +19,47 @@ const CaretButton = (): JSX.Element => { width: '20px', height: '100%', alignItems: 'center', - bg: '#6A6968', + bg: '$ctaBlue', border: '0px solid transparent', borderTopRightRadius: '5px', borderBottomRightRadius: '5px', borderTopLeftRadius: '0px', borderBottomLeftRadius: '0px', + '--caret-color': '#EDEDED', + '&:hover': { + opacity: 1.0, + color: 'white', + '--caret-color': 'white', + }, + '&:focus': { + outline: 'none', + border: '0px solid transparent', + }, }} > - + ) } export const SplitButton = (props: SplitButtonProps): JSX.Element => { return ( - + - {/* */} - }> + {/* }> console.log()} title="Archive (e)" /> - + */} ) } diff --git a/packages/web/components/elements/icons/HeaderCheckboxIcon.tsx b/packages/web/components/elements/icons/HeaderCheckboxIcon.tsx index 28620284f..6470053df 100644 --- a/packages/web/components/elements/icons/HeaderCheckboxIcon.tsx +++ b/packages/web/components/elements/icons/HeaderCheckboxIcon.tsx @@ -1,40 +1,56 @@ /* eslint-disable functional/no-class */ /* eslint-disable functional/no-this-expression */ import { IconProps } from './IconProps' +import { SpanBox } from '../LayoutPrimitives' import React from 'react' export class HeaderCheckboxIcon extends React.Component { render() { - const size = (this.props.size || 26).toString() - const color = (this.props.color || '#2A2A2A').toString() - return ( - - - - + - - + + + + + ) } } diff --git a/packages/web/components/elements/icons/HeaderSearchIcon.tsx b/packages/web/components/elements/icons/HeaderSearchIcon.tsx index e26a1811e..9444c1d6b 100644 --- a/packages/web/components/elements/icons/HeaderSearchIcon.tsx +++ b/packages/web/components/elements/icons/HeaderSearchIcon.tsx @@ -1,47 +1,65 @@ /* eslint-disable functional/no-class */ /* eslint-disable functional/no-this-expression */ +import { SpanBox } from '../LayoutPrimitives' import { IconProps } from './IconProps' import React from 'react' export class HeaderSearchIcon extends React.Component { render() { - const size = (this.props.size || 26).toString() - const color = (this.props.color || '#2A2A2A').toString() - return ( - - - - + - - - + + + + + + ) } } diff --git a/packages/web/components/elements/icons/HeaderToggleGridIcon.tsx b/packages/web/components/elements/icons/HeaderToggleGridIcon.tsx index 636a0e6fc..4a580bb03 100644 --- a/packages/web/components/elements/icons/HeaderToggleGridIcon.tsx +++ b/packages/web/components/elements/icons/HeaderToggleGridIcon.tsx @@ -1,61 +1,83 @@ /* eslint-disable functional/no-class */ /* eslint-disable functional/no-this-expression */ +import { SpanBox } from '../LayoutPrimitives' import { IconProps } from './IconProps' import React from 'react' export class HeaderToggleGridIcon extends React.Component { render() { - const size = (this.props.size || 26).toString() - const color = (this.props.color || '#2A2A2A').toString() - return ( - - - - + - - - - - + + + + + + + + ) } } diff --git a/packages/web/components/elements/icons/HeaderToggleListIcon.tsx b/packages/web/components/elements/icons/HeaderToggleListIcon.tsx index 44ac56613..6ea8cf88b 100644 --- a/packages/web/components/elements/icons/HeaderToggleListIcon.tsx +++ b/packages/web/components/elements/icons/HeaderToggleListIcon.tsx @@ -1,47 +1,65 @@ /* eslint-disable functional/no-class */ /* eslint-disable functional/no-this-expression */ +import { SpanBox } from '../LayoutPrimitives' import { IconProps } from './IconProps' import React from 'react' export class HeaderToggleListIcon extends React.Component { render() { - const size = (this.props.size || 26).toString() - const color = (this.props.color || '#2A2A2A').toString() - return ( - - - - + - - - + + + + + + ) } } diff --git a/packages/web/components/patterns/LibraryCards/LibraryCardStyles.tsx b/packages/web/components/patterns/LibraryCards/LibraryCardStyles.tsx index e0b6a545f..66a2fa81c 100644 --- a/packages/web/components/patterns/LibraryCards/LibraryCardStyles.tsx +++ b/packages/web/components/patterns/LibraryCards/LibraryCardStyles.tsx @@ -45,7 +45,7 @@ export const TitleStyle = { fontSize: '16px', fontWeight: '700', maxLines: 2, - lineHeight: 1.25, + lineHeight: 1.5, fontFamily: '$display', overflow: 'hidden', textOverflow: 'ellipsis', diff --git a/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx b/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx index 82af009e8..fc8cf7e29 100644 --- a/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx +++ b/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx @@ -64,15 +64,14 @@ export function LibraryGridCard(props: LinkedItemCardProps): JSX.Element { css={{ pl: '0px', padding: '0px', - width: '320px', + width: '293px', height: '100%', minHeight: '270px', background: 'white', borderRadius: '5px', borderWidth: '1px', - borderStyle: 'solid', + borderStyle: 'none', overflow: 'hidden', - borderColor: '$thBorderColor', cursor: 'pointer', '@media (max-width: 930px)': { m: '15px', @@ -88,6 +87,10 @@ export function LibraryGridCard(props: LinkedItemCardProps): JSX.Element { setIsHovered(false) }} onClick={(event) => { + if (props.multiSelectMode !== 'off') { + props.setIsChecked(props.item.id, !props.isChecked) + return + } if (event.metaKey || event.ctrlKey) { window.open( `/${props.viewer.profile.username}/${props.item.slug}`, diff --git a/packages/web/components/patterns/LibraryCards/LibraryListCard.tsx b/packages/web/components/patterns/LibraryCards/LibraryListCard.tsx index 7afcd42f4..524c76848 100644 --- a/packages/web/components/patterns/LibraryCards/LibraryListCard.tsx +++ b/packages/web/components/patterns/LibraryCards/LibraryListCard.tsx @@ -66,7 +66,7 @@ export function LibraryListCard(props: LinkedItemCardProps): JSX.Element { height: '100%', cursor: 'pointer', gap: '10px', - border: '1px solid $grayBorder', + borderStyle: 'none', borderBottom: 'none', borderRadius: '6px', width: '100vw', @@ -74,24 +74,22 @@ export function LibraryListCard(props: LinkedItemCardProps): JSX.Element { width: `calc(100vw - ${LIBRARY_LEFT_MENU_WIDTH})`, }, '@media (min-width: 930px)': { - width: '640px', + width: '580px', }, '@media (min-width: 1280px)': { - width: '1000px', + width: '890px', }, '@media (min-width: 1600px)': { - width: '1340px', - }, - boxShadow: - '0 1px 3px 0 rgba(0, 0, 0, 0.1),0 1px 2px 0 rgba(0, 0, 0, 0.06);', - '@media (max-width: 930px)': { - boxShadow: 'unset', - borderRadius: 'unset', + width: '1200px', }, }} alignment="start" distribution="start" onClick={(event) => { + if (props.multiSelectMode !== 'off') { + props.setIsChecked(props.item.id, !props.isChecked) + return + } if (event.metaKey || event.ctrlKey) { window.open( `/${props.viewer.profile.username}/${props.item.slug}`, diff --git a/packages/web/components/templates/PrimaryDropdown.tsx b/packages/web/components/templates/PrimaryDropdown.tsx index 7a01cb2cd..5c44615e6 100644 --- a/packages/web/components/templates/PrimaryDropdown.tsx +++ b/packages/web/components/templates/PrimaryDropdown.tsx @@ -1,352 +1,395 @@ import { useRouter } from 'next/router' import { Moon, Sun } from 'phosphor-react' -import { ReactNode, useCallback } from 'react' +import { ReactNode, useCallback, useState } from 'react' import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery' import { currentTheme, updateTheme } from '../../lib/themeUpdater' import { Avatar } from '../elements/Avatar' import { AvatarDropdown } from '../elements/AvatarDropdown' import { - Dropdown, - DropdownOption, - DropdownSeparator, + Dropdown, + DropdownOption, + DropdownSeparator, } from '../elements/DropdownElements' import GridLayoutIcon from '../elements/images/GridLayoutIcon' import ListLayoutIcon from '../elements/images/ListLayoutIcon' -import { Box, HStack, VStack } from '../elements/LayoutPrimitives' +import { Box, HStack, SpanBox, VStack } from '../elements/LayoutPrimitives' import { StyledText } from '../elements/StyledText' import { styled, theme, ThemeId } from '../tokens/stitches.config' import { LayoutType } from './homeFeed/HomeFeedContainer' +import { DropdownMenu } from '@radix-ui/react-dropdown-menu' type PrimaryDropdownProps = { - children?: ReactNode - showThemeSection: boolean + children?: ReactNode + showThemeSection: boolean - layout?: LayoutType - updateLayout?: (layout: LayoutType) => void - - showAddLinkModal?: () => void + layout?: LayoutType + updateLayout?: (layout: LayoutType) => void } export type HeaderDropdownAction = - | 'navigate-to-install' - | 'navigate-to-feeds' - | 'navigate-to-emails' - | 'navigate-to-labels' - | 'navigate-to-rules' - | 'navigate-to-profile' - | 'navigate-to-subscriptions' - | 'navigate-to-api' - | 'navigate-to-integrations' - | 'navigate-to-saved-searches' - | 'increaseFontSize' - | 'decreaseFontSize' - | 'logout' + | 'navigate-to-install' + | 'navigate-to-feeds' + | 'navigate-to-emails' + | 'navigate-to-labels' + | 'navigate-to-rules' + | 'navigate-to-profile' + | 'navigate-to-subscriptions' + | 'navigate-to-api' + | 'navigate-to-integrations' + | 'navigate-to-saved-searches' + | 'increaseFontSize' + | 'decreaseFontSize' + | 'logout' + +type TriggerButtonProps = { + name: string +} + +const TriggerButton = (props: TriggerButtonProps): JSX.Element => { + return ( + + + + + {props.name} + + + ) +} export function PrimaryDropdown(props: PrimaryDropdownProps): JSX.Element { - const { viewerData } = useGetViewerQuery() - const router = useRouter() + const { viewerData } = useGetViewerQuery() + const router = useRouter() - const headerDropdownActionHandler = useCallback( - (action: HeaderDropdownAction) => { - switch (action) { - case 'navigate-to-install': - router.push('/settings/installation') - break - case 'navigate-to-feeds': - router.push('/settings/feeds') - break - case 'navigate-to-emails': - router.push('/settings/emails') - break - case 'navigate-to-labels': - router.push('/settings/labels') - break - case 'navigate-to-rules': - router.push('/settings/rules') - break - case 'navigate-to-subscriptions': - router.push('/settings/subscriptions') - break - case 'navigate-to-api': - router.push('/settings/api') - break - case 'navigate-to-integrations': - router.push('/settings/integrations') - break - case 'navigate-to-saved-searches': - router.push('/settings/saved-searches') - break - case 'logout': - document.dispatchEvent(new Event('logout')) - break - default: - break - } - }, - [router] - ) + const headerDropdownActionHandler = useCallback( + (action: HeaderDropdownAction) => { + switch (action) { + case 'navigate-to-install': + router.push('/settings/installation') + break + case 'navigate-to-feeds': + router.push('/settings/feeds') + break + case 'navigate-to-emails': + router.push('/settings/emails') + break + case 'navigate-to-labels': + router.push('/settings/labels') + break + case 'navigate-to-rules': + router.push('/settings/rules') + break + case 'navigate-to-subscriptions': + router.push('/settings/subscriptions') + break + case 'navigate-to-api': + router.push('/settings/api') + break + case 'navigate-to-integrations': + router.push('/settings/integrations') + break + case 'navigate-to-saved-searches': + router.push('/settings/saved-searches') + break + case 'logout': + document.dispatchEvent(new Event('logout')) + break + default: + break + } + }, + [router] + ) - if (!viewerData?.me) { - return <> - } + if (!viewerData?.me) { + return <> + } - return ( - - ) - } - css={{ width: '240px' }} + return ( + + } + css={{ width: '240px' }} + > + { + router.push('/settings/account') + event.preventDefault() + }} + > + + - + { - router.push('/settings/account') - event.preventDefault() + > + {viewerData.me.name} + + - - - {viewerData.me && ( - <> - - {viewerData.me.name} - - - {`@${viewerData.me.profile.username}`} - - - )} - - - - {props.showThemeSection && } - headerDropdownActionHandler('navigate-to-install')} - title="Install" - /> - headerDropdownActionHandler('navigate-to-feeds')} - title="Feeds" - /> - headerDropdownActionHandler('navigate-to-emails')} - title="Emails" - /> - headerDropdownActionHandler('navigate-to-labels')} - title="Labels" - /> - headerDropdownActionHandler('navigate-to-rules')} - title="Rules" - /> - {props.showAddLinkModal && ( - <> - + > + {`@${viewerData.me.profile.username}`} + + + )} + + + + {props.showThemeSection && } + headerDropdownActionHandler('navigate-to-install')} + title="Install" + /> + headerDropdownActionHandler('navigate-to-feeds')} + title="Feeds" + /> + headerDropdownActionHandler('navigate-to-emails')} + title="Emails" + /> + headerDropdownActionHandler('navigate-to-labels')} + title="Labels" + /> + headerDropdownActionHandler('navigate-to-rules')} + title="Rules" + /> + + headerDropdownActionHandler('navigate-to-api')} + title="API Keys" + /> + + headerDropdownActionHandler('navigate-to-integrations') + } + title="Integrations" + /> + - props.showAddLinkModal && props.showAddLinkModal()} - title="Add Link" - /> - - )} - headerDropdownActionHandler('navigate-to-api')} - title="API Keys" - /> - headerDropdownActionHandler('navigate-to-integrations')} - title="Integrations" - /> - window.open('https://docs.omnivore.app', '_blank')} - title="Documentation" - /> - window.Intercom('show')} - title="Feedback" - /> - - headerDropdownActionHandler('logout')} - title="Logout" - /> - - ) + window.open('https://docs.omnivore.app', '_blank')} + title="Documentation" + /> + window.Intercom('show')} + title="Feedback" + /> + + headerDropdownActionHandler('logout')} + title="Logout" + /> + + ) } export const StyledToggleButton = styled('button', { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - color: '$thTextContrast2', - backgroundColor: 'transparent', - border: 'none', - cursor: 'pointer', - width: '70px', - height: '100%', - borderRadius: '5px', - fontSize: '12px', - fontFamily: '$inter', - gap: '5px', - m: '2px', - '&:hover': { - opacity: 0.8, - }, - '&[data-state="on"]': { - bg: '$thBackground', - }, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: '$thTextContrast2', + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + width: '70px', + height: '100%', + borderRadius: '5px', + fontSize: '12px', + fontFamily: '$inter', + gap: '5px', + m: '2px', + '&:hover': { + opacity: 0.8, + }, + '&[data-state="on"]': { + bg: '$thBackground', + }, }) function ThemeSection(props: PrimaryDropdownProps): JSX.Element { - return ( - <> - - - - Mode - - - { - updateTheme(ThemeId.Light) - }} - > - Light - - - { - updateTheme(ThemeId.Dark) - }} - > - Dark - - - - - {props.layout && ( - - - Layout - - - { - props.updateLayout && props.updateLayout('LIST_LAYOUT') - }} - > - - - { - props.updateLayout && props.updateLayout('GRID_LAYOUT') - }} - > - - - - - )} - - - - ) + const [displayTheme, setDisplayTheme] = useState(currentTheme()) + + const doUpdateTheme = useCallback( + (newTheme: ThemeId) => { + updateTheme(newTheme) + setDisplayTheme(newTheme) + }, + [displayTheme, setDisplayTheme] + ) + + return ( + <> + + + + Mode + + + { + doUpdateTheme(ThemeId.Light) + }} + > + Light + + + { + doUpdateTheme(ThemeId.Dark) + }} + > + Dark + + + + + {props.layout && ( + + + Layout + + + { + props.updateLayout && props.updateLayout('LIST_LAYOUT') + }} + > + + + { + props.updateLayout && props.updateLayout('GRID_LAYOUT') + }} + > + + + + + )} + + + + ) } diff --git a/packages/web/components/templates/article/HighlightsLayer.tsx b/packages/web/components/templates/article/HighlightsLayer.tsx index 4d005f69d..d5bb8b0bb 100644 --- a/packages/web/components/templates/article/HighlightsLayer.tsx +++ b/packages/web/components/templates/article/HighlightsLayer.tsx @@ -573,7 +573,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element { ) useEffect(() => { - if (props.highlightOnRelease && selectionData?.wasDragEvent) { + if (props.highlightOnRelease) { handleAction('create') setSelectionData(null) } diff --git a/packages/web/components/templates/article/ReaderSettingsControl.tsx b/packages/web/components/templates/article/ReaderSettingsControl.tsx index 490c92478..3c0c0665f 100644 --- a/packages/web/components/templates/article/ReaderSettingsControl.tsx +++ b/packages/web/components/templates/article/ReaderSettingsControl.tsx @@ -167,6 +167,37 @@ function AdvancedSettings(props: SettingsProps): JSX.Element { + + + + { + readerSettings.setHighlightOnRelease(checked) + }} + > + + + ) } diff --git a/packages/web/components/templates/homeFeed/AddLinkModal.tsx b/packages/web/components/templates/homeFeed/AddLinkModal.tsx index 34b51eb08..1317c0a88 100644 --- a/packages/web/components/templates/homeFeed/AddLinkModal.tsx +++ b/packages/web/components/templates/homeFeed/AddLinkModal.tsx @@ -1,11 +1,13 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' +import * as Progress from '@radix-ui/react-progress' +import { File, Info } from 'phosphor-react' import toast from 'react-hot-toast' import { locale, timeZone } from '../../../lib/dateFormatting' import { saveUrlMutation } from '../../../lib/networking/mutations/saveUrlMutation' -import { showErrorToast } from '../../../lib/toastHelpers' +import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers' import { Button } from '../../elements/Button' import { FormInput } from '../../elements/FormElements' -import { Box, VStack } from '../../elements/LayoutPrimitives' +import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' import { ModalButtonBar, ModalContent, @@ -13,6 +15,29 @@ import { ModalRoot, ModalTitleBar, } from '../../elements/ModalPrimitives' +import { CloseButton } from '../../elements/CloseButton' +import { StyledText } from '../../elements/StyledText' +import { styled } from '@stitches/react' +import Dropzone, { + Accept, + DropEvent, + DropzoneRef, + FileRejection, +} from 'react-dropzone' +import { v4 as uuidv4 } from 'uuid' +import { validateCsvFile } from '../../../utils/csvValidator' +import { + uploadImportFileRequestMutation, + UploadImportFileType, +} from '../../../lib/networking/mutations/uploadImportFileMutation' +import { uploadFileRequestMutation } from '../../../lib/networking/mutations/uploadFileMutation' +import axios from 'axios' +import { theme } from '../../tokens/stitches.config' +import { formatMessage } from '../../../locales/en/messages' +import { subscribeMutation } from '../../../lib/networking/mutations/subscribeMutation' +import { SubscriptionType } from '../../../lib/networking/queries/useGetSubscriptionsQuery' + +type TabName = 'link' | 'feed' | 'opml' | 'pdf' | 'import' type AddLinkModalProps = { onOpenChange: (open: boolean) => void @@ -24,9 +49,127 @@ type AddLinkModalProps = { } export function AddLinkModal(props: AddLinkModalProps): JSX.Element { - const [link, setLink] = useState('') + const [selectedTab, setSelectedTab] = useState('link') - const validateLink = useCallback( + return ( + + + { + // remove focus from modal + ;(document.activeElement as HTMLElement).blur() + }} + > + + + + {selectedTab == 'link' && } + {selectedTab == 'feed' && } + {selectedTab == 'opml' && } + {selectedTab == 'pdf' && } + {selectedTab == 'import' && } + + + + + ) +} + +const AddLinkTab = (props: AddLinkModalProps): JSX.Element => { + const [errorMessage, setErrorMessage] = useState( + undefined + ) + + const addLink = useCallback( + async (link: string) => { + await props.handleLinkSubmission(link, timeZone, locale) + props.onOpenChange(false) + }, + [errorMessage, setErrorMessage] + ) + + return ( + + ) +} + +const AddFeedTab = (props: AddLinkModalProps): JSX.Element => { + const [errorMessage, setErrorMessage] = useState( + undefined + ) + + const subscribe = useCallback( + async (feedUrl: string) => { + if (!feedUrl) { + setErrorMessage('Please enter a valid feed URL') + return + } + + let normailizedUrl: string + // normalize the url + try { + normailizedUrl = new URL(feedUrl.trim()).toString() + } catch (e) { + setErrorMessage('Please enter a valid feed URL') + return + } + + const result = await subscribeMutation({ + url: normailizedUrl, + subscriptionType: SubscriptionType.RSS, + }) + + if (result.subscribe.errorCodes) { + const errorMessage = formatMessage({ + id: `error.${result.subscribe.errorCodes[0]}`, + }) + setErrorMessage(`There was an error adding new feed: ${errorMessage}`) + return + } + + showSuccessToast('New feed has been added.') + }, + [errorMessage, setErrorMessage] + ) + + return ( + + ) +} + +type AddFromURLProps = { + placeholder: string + errorMessage: string | undefined + setErrorMessage: (message: string) => void + onSubmit: (url: string) => Promise +} + +const AddFromURL = (props: AddFromURLProps): JSX.Element => { + const [url, setURL] = useState('') + const [errorMessage, setErrorMessage] = useState(props.errorMessage) + + const validateURL = useCallback( (link: string) => { try { const url = new URL(link) @@ -38,66 +181,619 @@ export function AddLinkModal(props: AddLinkModalProps): JSX.Element { } return true }, - [link] + [url] ) return ( - - - { - // remove focus from modal - ;(document.activeElement as HTMLElement).blur() + +
{ + event.preventDefault() + + if (!validateURL(url)) { + setErrorMessage('Invalid URL') + return + } + + props.onSubmit(url) }} > - - - - { - event.preventDefault() - - let submitLink = link - if (!validateLink(link)) { - // If validation fails, attempting adding - // `https` to give the link a protocol. - const newLink = `https://${link}` - if (!validateLink(newLink)) { - showErrorToast('Invalid link', { position: 'bottom-right' }) - return - } - setLink(newLink) - submitLink = newLink - } - await props.handleLinkSubmission(submitLink, timeZone, locale) - props.onOpenChange(false) - }} - > - setLink(event.target.value)} - css={{ - borderRadius: '8px', - border: '1px solid $textNonessential', - width: '100%', - height: '38px', - p: '6px', - mb: '13px', - fontSize: '14px', - }} - /> - - - -
-
-
+ setURL(event.target.value)} + css={{ + borderRadius: '4px', + width: '100%', + height: '38px', + p: '6px', + mb: '13px', + fontSize: '14px', + color: '$thTextContrast', + bg: '$thFormInput', + }} + /> + + + + ) +} + +const UploadOPMLTab = (props: AddLinkModalProps): JSX.Element => { + return ( + + + + ) +} + +const UploadPDFTab = (props: AddLinkModalProps): JSX.Element => { + return ( + + + + PDFs have a maximum size of 8MB.{' '} + + } + description="Drag PDFs here to add to your library" + accept={{ + 'application/pdf': ['.pdf'], + }} + /> + + ) +} + +const UploadImportTab = (props: AddLinkModalProps): JSX.Element => { + return ( + + + + Imports must be in a supported format.{' '} + + Read more + + + } + description="Drop import files here" + accept={{ + 'text/csv': ['.csv'], + 'application/zip': ['.zip'], + }} + /> + + ) +} + +const DragnDropContainer = styled('div', { + width: '100%', + height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: '1', + alignSelf: 'center', + left: 0, + flexDirection: 'column', +}) + +const DragnDropStyle = styled('div', { + border: '1px solid $grayBorder', + borderRadius: '5px', + width: '100%', + height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + alignSelf: 'center', + color: '$thTextSubtle2', + padding: '10px', +}) + +const DragnDropIndicator = styled('div', { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + alignSelf: 'center', + width: '100%', + height: '100%', + borderRadius: '5px', +}) + +const ProgressIndicator = styled(Progress.Indicator, { + backgroundColor: '$omnivoreCtaYellow', + width: '100%', + height: '100%', +}) + +const ProgressRoot = styled(Progress.Root, { + position: 'relative', + overflow: 'hidden', + background: '$omnivoreGray', + borderRadius: '99999px', + width: '100%', + height: '5px', + transform: 'translateZ(0)', +}) + +type UploadingFile = { + id: string + file: any + name: string + progress: number + status: 'inprogress' | 'success' | 'error' + openUrl: string | undefined + contentType: string + message?: string +} + +type UploadInfo = { + uploadSignedUrl?: string + requestId?: string + message?: string +} + +type UploadPadProps = { + info?: React.ReactNode + description: string + accept: Accept +} + +const UploadPad = (props: UploadPadProps): JSX.Element => { + const [uploadFiles, setUploadFiles] = useState([]) + const [inDragOperation, setInDragOperation] = useState(false) + const dropzoneRef = useRef(null) + + const openDialog = useCallback( + (event: React.MouseEvent) => { + if (dropzoneRef.current) { + dropzoneRef.current.open() + } + event?.preventDefault() + }, + [dropzoneRef] + ) + + const uploadSignedUrlForFile = async ( + file: UploadingFile + ): Promise => { + let { contentType } = file + if ( + contentType == 'application/vnd.ms-excel' && + file.name.endsWith('.csv') + ) { + contentType = 'text/csv' + } + switch (contentType) { + case 'text/csv': { + let urlCount = 0 + try { + const csvData = await validateCsvFile(file.file) + urlCount = csvData.data.length + if (urlCount > 5000) { + return { + message: + 'Due to an increase in traffic we are limiting CSV imports to 5000 items.', + } + } + if (csvData.inValidData.length > 0) { + return { + message: csvData.inValidData[0].message, + } + } + if (urlCount === 0) { + return { + message: 'No URLs found in CSV file.', + } + } + } catch (error) { + return { + message: 'Invalid CSV file.', + } + } + + try { + const result = await uploadImportFileRequestMutation( + UploadImportFileType.URL_LIST, + contentType + ) + return { + uploadSignedUrl: result?.uploadSignedUrl, + message: `Importing ${urlCount} URLs`, + } + } catch (error) { + console.log('caught error', error) + if (error == 'UPLOAD_DAILY_LIMIT_EXCEEDED') { + return { + message: 'You have exceeded your maximum daily upload limit.', + } + } + } + } + case 'application/zip': { + const result = await uploadImportFileRequestMutation( + UploadImportFileType.MATTER, + contentType + ) + return { + uploadSignedUrl: result?.uploadSignedUrl, + } + } + case 'application/pdf': + case 'application/epub+zip': { + const request = await uploadFileRequestMutation({ + // This will tell the backend not to save the URL + // and give it the local filename as the title. + url: `file://local/${file.id}/${file.file.path}`, + contentType: contentType, + createPageEntry: true, + }) + return { + uploadSignedUrl: request?.uploadSignedUrl, + requestId: request?.createdPageId, + } + } + } + return { + message: `Invalid content type: ${contentType}`, + } + } + + const handleAcceptedFiles = useCallback( + (acceptedFiles: any, event: DropEvent) => { + setInDragOperation(false) + + const addedFiles = acceptedFiles.map( + (file: { name: any; type: string }) => { + return { + id: uuidv4(), + file: file, + name: file.name, + progress: 0, + status: 'inprogress', + contentType: file.type, + } + } + ) + + const allFiles = [...uploadFiles, ...addedFiles] + + setUploadFiles(allFiles) + ;(async () => { + for (const file of addedFiles) { + try { + const uploadInfo = await uploadSignedUrlForFile(file) + if (!uploadInfo.uploadSignedUrl) { + const message = uploadInfo.message || 'No upload URL available' + showErrorToast(message, { duration: 10000 }) + file.status = 'error' + setUploadFiles([...allFiles]) + return + } + + const uploadResult = await axios.request({ + method: 'PUT', + url: uploadInfo.uploadSignedUrl, + data: file.file, + withCredentials: false, + headers: { + 'Content-Type': file.file.type, + }, + onUploadProgress: (p) => { + if (!p.total) { + console.warn('No total available for upload progress') + return + } + const progress = (p.loaded / p.total) * 100 + file.progress = progress + + setUploadFiles([...allFiles]) + }, + }) + + file.progress = 100 + file.status = 'success' + file.openUrl = uploadInfo.requestId + ? `/article/sr/${uploadInfo.requestId}` + : undefined + file.message = uploadInfo.message + + setUploadFiles([...allFiles]) + } catch (error) { + file.status = 'error' + setUploadFiles([...allFiles]) + } + } + })() + }, + [uploadFiles] + ) + + return ( + + {props.info && ( + + {props.info} + + )} + { + setInDragOperation(true) + }} + onDragLeave={() => { + setInDragOperation(false) + }} + onDropAccepted={handleAcceptedFiles} + onDropRejected={(fileRejections: FileRejection[], event: DropEvent) => { + console.log('onDropRejected: ', fileRejections, event) + alert('You can only upload PDF files to your Omnivore Library.') + setInDragOperation(false) + event.preventDefault() + }} + preventDropOnDocument={true} + noClick={true} + accept={props.accept} + > + {({ getRootProps, getInputProps, acceptedFiles, fileRejections }) => ( +
+ + + + + + {inDragOperation ? ( + <> + + Drop to upload your file + + + ) : ( + <> + + {props.description} +
or{' '} + + choose your files + +
+ + )} +
+
+
+ + {uploadFiles.map((file) => { + return ( + + + {file.name} + + {file.status != 'inprogress' ? ( + + {file.status == 'success' && file.openUrl && ( + Read Now + )} + {file.status == 'success' && !file.openUrl && ( + + {file.message || 'Your import has started'} + + )} + {file.status == 'error' && ( + + Error Uploading + + )} + + ) : ( + + {' '} + + )} + + ) + })} + +
+ +
+ )} +
+
+ ) +} + +type TabBarProps = { + selectedTab: string + setSelectedTab: (selected: TabName) => void + + onOpenChange: (open: boolean) => void +} + +const TabBar = (props: TabBarProps) => { + return ( + + + + + {/* */} + + + + props.onOpenChange(false)} /> + + ) } diff --git a/packages/web/components/templates/homeFeed/EmptyHighlights.tsx b/packages/web/components/templates/homeFeed/EmptyHighlights.tsx index 1972ee1be..ef822a3a4 100644 --- a/packages/web/components/templates/homeFeed/EmptyHighlights.tsx +++ b/packages/web/components/templates/homeFeed/EmptyHighlights.tsx @@ -2,10 +2,9 @@ import { Book } from 'phosphor-react' import { VStack } from '../../elements/LayoutPrimitives' import { StyledText } from '../../elements/StyledText' import { theme } from '../../tokens/stitches.config' -import { useGetHeaderHeight } from './HeaderSpacer' +import { DEFAULT_HEADER_HEIGHT } from './HeaderSpacer' export function EmptyHighlights(): JSX.Element { - const headerHeight = useGetHeaderHeight() return ( diff --git a/packages/web/components/templates/homeFeed/HeaderSpacer.tsx b/packages/web/components/templates/homeFeed/HeaderSpacer.tsx index 998878190..30f64f3bd 100644 --- a/packages/web/components/templates/homeFeed/HeaderSpacer.tsx +++ b/packages/web/components/templates/homeFeed/HeaderSpacer.tsx @@ -2,32 +2,32 @@ import { usePersistedState } from '../../../lib/hooks/usePersistedState' import { PinnedSearch } from '../../../pages/settings/pinned-searches' import { Box } from '../../elements/LayoutPrimitives' -export const DEFAULT_HEADER_HEIGHT = '70px' +export const DEFAULT_HEADER_HEIGHT = '85px' -export const useGetHeaderHeight = () => { - const [hidePinnedSearches] = usePersistedState({ - key: '--library-hide-pinned-searches', - initialValue: false, - isSessionStorage: false, - }) - const [pinnedSearches] = usePersistedState({ - key: `--library-pinned-searches`, - initialValue: [], - isSessionStorage: false, - }) +// export const useGetHeaderHeight = () => { +// const [hidePinnedSearches] = usePersistedState({ +// key: '--library-hide-pinned-searches', +// initialValue: false, +// isSessionStorage: false, +// }) +// const [pinnedSearches] = usePersistedState({ +// key: `--library-pinned-searches`, +// initialValue: [], +// isSessionStorage: false, +// }) - if (hidePinnedSearches || !pinnedSearches?.length) { - return '70px' - } - return '100px' -} +// if (hidePinnedSearches || !pinnedSearches?.length) { +// return '90px' +// } +// return '90px' +// } export function HeaderSpacer(): JSX.Element { - const headerHeight = useGetHeaderHeight() + // const headerHeight = useGetHeaderHeight() return ( ( undefined ) @@ -106,7 +106,7 @@ export function HighlightItemsLayout( void @@ -956,24 +962,23 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element { width: props.mode == 'highlights' ? '100%' : 'unset', }} > - { - props.applySearchQuery(searchQuery) - }} - handleLinkSubmission={props.handleLinkSubmission} - 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} - /> + {props.mode != 'highlights' && ( + { + props.applySearchQuery(searchQuery) + }} + showFilterMenu={showFilterMenu} + setShowFilterMenu={setShowFilterMenu} + multiSelectMode={props.multiSelectMode} + setMultiSelectMode={props.setMultiSelectMode} + numItemsSelected={props.numItemsSelected} + performMultiSelectAction={props.performMultiSelectAction} + /> + )} + void } & HomeFeedContentProps -function LibraryItemsLayout(props: LibraryItemsLayoutProps): JSX.Element { +export function LibraryItemsLayout( + props: LibraryItemsLayoutProps +): JSX.Element { const [showUnsubscribeConfirmation, setShowUnsubscribeConfirmation] = useState(false) const [showUploadModal, setShowUploadModal] = useState(false) @@ -1039,6 +1046,14 @@ function LibraryItemsLayout(props: LibraryItemsLayoutProps): JSX.Element { setShowUnsubscribeConfirmation(false) } + const [pinnedSearches, setPinnedSearches] = usePersistedState< + PinnedSearch[] | null + >({ + key: `--library-pinned-searches`, + initialValue: [], + isSessionStorage: false, + }) + return ( <> + + + + {props.isValidating && props.items.length == 0 && }
{ @@ -1234,7 +1272,8 @@ function LibraryItems(props: LibraryItemsProps): JSX.Element { outline: 'none', }, '&> div': { - bg: '$thBackground3', + bg: '$thLeftMenuBackground', + // bg: '$thLibraryBackground', }, '&:focus': { outline: 'none', @@ -1246,6 +1285,7 @@ function LibraryItems(props: LibraryItemsProps): JSX.Element { '&:hover': { '> div': { bg: '$thBackgroundActive', + boxShadow: '$cardBoxShadow', }, '> a': { bg: '$thBackgroundActive', diff --git a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx index 0c9000ea2..958b30b73 100644 --- a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx +++ b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx @@ -19,8 +19,11 @@ import { SavedSearch } from '../../../lib/networking/fragments/savedSearchFragme import { ToggleCaretDownIcon } from '../../elements/icons/ToggleCaretDownIcon' import Link from 'next/link' import { ToggleCaretRightIcon } from '../../elements/icons/ToggleCaretRightIcon' +import { SplitButton } from '../../elements/SplitButton' +import { AvatarDropdown } from '../../elements/AvatarDropdown' +import { PrimaryDropdown } from '../PrimaryDropdown' -export const LIBRARY_LEFT_MENU_WIDTH = '233px' +export const LIBRARY_LEFT_MENU_WIDTH = '275px' type LibraryFilterMenuProps = { setShowAddLinkModal: (show: boolean) => void @@ -119,6 +122,7 @@ export function LibraryFilterMenu(props: LibraryFilterMenuProps): JSX.Element { +