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/Button.tsx b/packages/web/components/elements/Button.tsx
index eea66f75a..df08e5fbb 100644
--- a/packages/web/components/elements/Button.tsx
+++ b/packages/web/components/elements/Button.tsx
@@ -18,6 +18,21 @@ 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',
+ '&:hover': {
+ opacity: '0.6',
+ border: '0px solid $ctaBlue',
+ },
+ },
+
ctaDarkYellow: {
border: '1px solid transparent',
fontSize: '14px',
@@ -215,6 +230,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: '$thBorderSubtle',
+ cursor: 'pointer',
+ borderRadius: '5px',
+ '&:hover': {
+ color: '$thTextContrast',
+ },
+ },
+ tabSelected: {
+ px: '15px',
+ py: '6px',
+ border: 'none',
+ bg: '$thBorderSubtle',
+ fontSize: '12px',
+ fontWeight: '500',
+ fontFamily: '$inter',
+ color: '$textContrast',
+ 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 {
void
}
const CaretButton = (): JSX.Element => {
@@ -41,35 +44,39 @@ const CaretButton = (): JSX.Element => {
export const SplitButton = (props: SplitButtonProps): JSX.Element => {
return (
-
+
- }>
+ {/* }>
console.log()} title="Archive (e)" />
-
+ */}
)
}
diff --git a/packages/web/components/templates/PrimaryDropdown.tsx b/packages/web/components/templates/PrimaryDropdown.tsx
index 49ec705e3..906c44c59 100644
--- a/packages/web/components/templates/PrimaryDropdown.tsx
+++ b/packages/web/components/templates/PrimaryDropdown.tsx
@@ -49,12 +49,12 @@ const TriggerButton = (props: TriggerButtonProps): JSX.Element => {
return (
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,577 @@ export function AddLinkModal(props: AddLinkModalProps): JSX.Element {
}
return true
},
- [link]
+ [url]
)
return (
-
-
- {
- // remove focus from modal
- ;(document.activeElement as HTMLElement).blur()
+
+
-
+ setURL(event.target.value)}
+ css={{
+ borderRadius: '4px',
+ width: '100%',
+ height: '38px',
+ p: '6px',
+ mb: '13px',
+ fontSize: '14px',
+ color: '$thTextContrast',
+ bg: '$thLibrarySearchbox',
+ }}
+ />
+
+
+
+ )
+}
+
+const UploadOPMLTab = (props: AddLinkModalProps): JSX.Element => {
+ return (
+
+
+
+ )
+}
+
+const UploadPDFTab = (props: AddLinkModalProps): JSX.Element => {
+ return (
+
+
+
+ * PDFs have a maximum size of 8MB
+
+
+ )
+}
+
+const UploadImportTab = (props: AddLinkModalProps): JSX.Element => {
+ return (
+
+
+
+ * Imports must be in a supported format{' '}
+
+ read more
+
+ .
+
+
+ )
+}
+
+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 = {
+ 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 (
+
+ {
+ 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/HomeFeedContainer.tsx b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx
index 393ecdd11..a7cdde844 100644
--- a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx
+++ b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx
@@ -963,9 +963,6 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
applySearchQuery={(searchQuery: string) => {
props.applySearchQuery(searchQuery)
}}
- handleLinkSubmission={props.handleLinkSubmission}
- allowSelectMultiple={props.mode !== 'highlights'}
- alwaysShowHeader={props.mode == 'highlights'}
showFilterMenu={showFilterMenu}
setShowFilterMenu={setShowFilterMenu}
multiSelectMode={props.multiSelectMode}
@@ -1234,7 +1231,8 @@ function LibraryItems(props: LibraryItemsProps): JSX.Element {
outline: 'none',
},
'&> div': {
- bg: '$thBackground3',
+ bg: '$thLeftMenuBackground',
+ // bg: '$thLibraryBackground',
},
'&:focus': {
outline: 'none',
@@ -1246,6 +1244,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 b9a5a8be8..74cc9b3dc 100644
--- a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx
+++ b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx
@@ -122,7 +122,7 @@ export function LibraryFilterMenu(props: LibraryFilterMenuProps): JSX.Element {
-
+
{/* This spacer pushes library content to the right of
@@ -611,16 +611,18 @@ function EditButton(props: EditButtonProps): JSX.Element {
)
}
-const Footer = (): JSX.Element => {
+const Footer = (props: LibraryFilterMenuProps): JSX.Element => {
return (
{
css={{
marginLeft: 'auto',
marginRight: '5px',
- '&:hover': { opacity: 1.0, color: 'white' },
}}
>
-
+ props.setShowAddLinkModal(true)}
+ />
)
diff --git a/packages/web/components/templates/homeFeed/LibraryHeader.tsx b/packages/web/components/templates/homeFeed/LibraryHeader.tsx
index 03a7e286b..c957b8c3c 100644
--- a/packages/web/components/templates/homeFeed/LibraryHeader.tsx
+++ b/packages/web/components/templates/homeFeed/LibraryHeader.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { theme } from '../../tokens/stitches.config'
import { FormInput } from '../../elements/FormElements'
@@ -41,6 +41,7 @@ import { HeaderCheckboxIcon } from '../../elements/icons/HeaderCheckboxIcon'
import { HeaderSearchIcon } from '../../elements/icons/HeaderSearchIcon'
import { HeaderToggleGridIcon } from '../../elements/icons/HeaderToggleGridIcon'
import { HeaderToggleListIcon } from '../../elements/icons/HeaderToggleListIcon'
+import useWindowDimensions from '../../../lib/hooks/useGetWindowDimensions'
export type MultiSelectMode = 'off' | 'none' | 'some' | 'visible' | 'search'
@@ -83,7 +84,6 @@ const controlWidths = (
}
export function LibraryHeader(props: LibraryHeaderProps): JSX.Element {
- const headerHeight = useGetHeaderHeight()
const [small, setSmall] = useState(false)
useEffect(() => {
@@ -105,7 +105,7 @@ export function LibraryHeader(props: LibraryHeaderProps): JSX.Element {
position: 'fixed',
left: LIBRARY_LEFT_MENU_WIDTH,
height: small ? '60px' : DEFAULT_HEADER_HEIGHT,
- transition: '0.5s',
+ transition: 'height 0.5s',
'@mdDown': {
left: '0px',
right: '0',
@@ -123,6 +123,7 @@ export function LibraryHeader(props: LibraryHeaderProps): JSX.Element {
}
function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element {
+ const dimensions = useWindowDimensions()
const [showSearchBar, setShowSearchBar] = useState(false)
const [pinnedSearches, setPinnedSearches] = usePersistedState<
PinnedSearch[] | null
@@ -132,6 +133,10 @@ function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element {
isSessionStorage: false,
})
+ const isWideWindow = useMemo(() => {
+ return dimensions.width >= 480
+ }, [dimensions])
+
return (
) : (
<>
-
-
-
-
+ {(!showSearchBar || isWideWindow) && (
+ <>
+
+
+
+
+ >
+ )}
{showSearchBar ? (
@@ -308,7 +317,7 @@ export function SearchBox(props: SearchBoxProps): JSX.Element {
bg: '$thLibrarySearchbox',
borderRadius: '100px',
border: focused
- ? '2px solid $omnivoreCtaYellow'
+ ? '2px solid $searchActiveOutline'
: '2px solid transparent',
boxShadow: focused
? 'none'
diff --git a/packages/web/components/tokens/stitches.config.ts b/packages/web/components/tokens/stitches.config.ts
index 9dd4e5b46..e4329b53f 100644
--- a/packages/web/components/tokens/stitches.config.ts
+++ b/packages/web/components/tokens/stitches.config.ts
@@ -103,7 +103,8 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
borderWidths: {},
borderStyles: {},
shadows: {
- cardBoxShadow: '0px 1px 2px 0px rgba(0, 0, 0, 0.05);',
+ // cardBoxShadow: '0px 1px 2px 0px rgba(0, 0, 0, 0.05);',
+ cardBoxShadow: '0px 4px 4px rgba(0, 0, 0, 0.20);',
},
zIndices: {},
transitions: {},
@@ -130,6 +131,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
grayText: '#6A6968',
ctaBlue: '#007AFF',
+ modalBackground: '#FFFFFF',
highlightBackground: '255, 210, 52',
recommendedHighlightBackground: '#E5FFE5',
@@ -143,6 +145,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
omnivoreYellow: 'rgb(255, 234, 159)',
omnivoreLightGray: 'rgb(125, 125, 125)',
omnivoreCtaYellow: 'rgb(255, 210, 52)',
+ searchActiveOutline: '#866D15',
// Reader Colors
readerBg: 'white',
@@ -234,6 +237,9 @@ const darkThemeSpec = {
colorScheme: {
colorScheme: 'dark',
},
+ shadows: {
+ cardBoxShadow: '0px 4px 8px rgba(0, 0, 0, 0.50);',
+ },
colors: {
grayBase: '#252525',
grayBg: '#3B3938',
@@ -250,6 +256,8 @@ const darkThemeSpec = {
grayBorderHover: 'hsl(0 0% 31.2%)',
grayText: '#CDCDCD',
+ modalBackground: '#2A2A2A',
+
// Semantic Colors
highlightBackground: '134, 109, 21',
recommendedHighlightBackground: '#1F4315',
diff --git a/packages/web/lib/hooks/useGetWindowDimensions.tsx b/packages/web/lib/hooks/useGetWindowDimensions.tsx
index d05c42e61..bfa2563eb 100644
--- a/packages/web/lib/hooks/useGetWindowDimensions.tsx
+++ b/packages/web/lib/hooks/useGetWindowDimensions.tsx
@@ -8,9 +8,10 @@ function getWindowDimensions() {
}
}
export default function useWindowDimensions() {
- const [windowDimensions, setWindowDimensions] = useState(
- getWindowDimensions()
- )
+ const [windowDimensions, setWindowDimensions] = useState({
+ width: 0,
+ height: 0,
+ })
useEffect(() => {
function handleResize() {