import { useCallback, useRef, useState } from 'react' import * as Progress from '@radix-ui/react-progress' import { File, Info } from '@phosphor-icons/react' import { locale, timeZone } from '../../lib/dateFormatting' import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' import { Button } from '../elements/Button' import { FormInput } from '../elements/FormElements' import { Box, HStack, SpanBox, VStack } from '../elements/LayoutPrimitives' import { ModalContent, ModalOverlay, ModalRoot, } from '../elements/ModalPrimitives' import { CloseButton } from '../elements/CloseButton' 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 handleLinkSubmission: ( link: string, timezone: string, locale: string ) => Promise } export function AddLinkModal(props: AddLinkModalProps): JSX.Element { const [selectedTab, setSelectedTab] = useState('link') return ( { event.preventDefault() }} > {/* */} {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) }, [props, 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.') props.onOpenChange(false) }, [props, 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 validateURL = useCallback((link: string) => { try { const url = new URL(link) if (url.protocol !== 'https:' && url.protocol !== 'http:') { return false } } catch (e) { return false } return true }, []) return (
{ event.preventDefault() if (!validateURL(url)) { props.setErrorMessage('Invalid URL') return } props.onSubmit(url) }} > setURL(event.target.value)} css={{ borderRadius: '4px', width: '100%', height: '38px', p: '6px', mb: '13px', fontSize: '14px', color: '$thTextContrast', bg: '$thFormInput', }} /> {props.errorMessage && ( {props.errorMessage} )}
) } const UploadOPMLTab = (): JSX.Element => { return ( ) } const UploadPDFTab = (): 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 ) : ( <> {(!uploadFiles || uploadFiles.length == 0) && ( {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)} /> ) }