├── .babelrc ├── .gitignore ├── README.md ├── components ├── AccessToggle.tsx ├── AuthButton.tsx ├── AuthDropdown.tsx ├── AuthDropdownContent.tsx ├── AuthDropdownTrigger.tsx ├── CommentRow.tsx ├── Comments.tsx ├── CurrentFile.tsx ├── Dropdown.tsx ├── EditFileName.tsx ├── EditUserName.tsx ├── FileCell.tsx ├── FileGrid.tsx ├── FileHead.tsx ├── FilePreview.tsx ├── Footer.tsx ├── FooterLink.tsx ├── Gradient.tsx ├── Head.tsx ├── Modal.tsx ├── Navbar.tsx ├── PDF.tsx ├── ProgressCircle.tsx ├── RecentlyUploadedFiles.tsx ├── Search.tsx ├── SignInButton.tsx ├── SignOutButton.tsx ├── SlackInstallButton.tsx ├── Spinner.tsx ├── Title.tsx ├── UploadButton.tsx ├── UploadDrop.tsx ├── UploadDropOverlay.tsx └── UploadFile.tsx ├── docs └── privacy.mdx ├── hooks ├── useComments.ts ├── useCurrentUser.ts ├── useFiles.ts ├── useHideOverlays.ts ├── useRecentlyUploadedFiles.ts ├── useSignIn.ts ├── useUpdate.ts └── useUser.ts ├── images ├── apple-touch-icon.png ├── icon-16x16.png ├── icon-32x32.png ├── icon.svg └── safari-pinned-tab.svg ├── lib ├── access.ts ├── clipBody.ts ├── constants.ts ├── deleteFile.ts ├── editAccess.ts ├── editFileName.ts ├── editUserName.ts ├── firebase │ ├── admin.ts │ └── index.ts ├── getFile.ts ├── getFileIcon.ts ├── getFileIds.ts ├── getFilePredicate.ts ├── getFileType.ts ├── getFileUrl.ts ├── getFiles.ts ├── getRecentlyUploadedFiles.ts ├── getUser.ts ├── getUserFromSlug.ts ├── getUserSlug.ts ├── getUserSlugs.ts ├── newId.ts ├── normalize.ts ├── normalizeExtension.ts ├── signIn.ts ├── signOut.ts ├── snapshotToComment.ts ├── snapshotToFileMeta.ts ├── snapshotToUser.ts ├── submitComment.ts ├── toSlug.ts └── uploadFile.ts ├── models ├── Comment.ts ├── CurrentUser.ts ├── FileMeta.ts ├── FileType.ts └── User.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── 404.tsx ├── [id].tsx ├── _app.tsx ├── _document.tsx ├── api │ ├── files │ │ ├── [id].ts │ │ └── index.ts │ └── users │ │ ├── id │ │ └── [id].ts │ │ └── slug │ │ └── [slug].ts ├── index.tsx ├── privacy.tsx ├── slack.tsx ├── support.tsx └── u │ └── [slug].tsx ├── public ├── browserconfig.xml ├── favicon.ico ├── images │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── mstile-150x150.png ├── robots.txt └── site.webmanifest ├── state ├── comments.ts ├── currentFile.ts ├── currentUser.ts ├── files.ts ├── recentlyUploadedFiles.ts ├── uploadFile.ts └── users.ts ├── styles ├── AccessToggle.module.scss ├── AuthDropdownContent.module.scss ├── AuthDropdownTrigger.module.scss ├── CommentRow.module.scss ├── Comments.module.scss ├── CurrentFile.module.scss ├── Dropdown.module.scss ├── EditFileName.module.scss ├── EditUserName.module.scss ├── FileCell.module.scss ├── FileGrid.module.scss ├── FilePage.module.scss ├── FilePreview.module.scss ├── Footer.module.scss ├── FooterLink.module.scss ├── Gradient.module.scss ├── Home.module.scss ├── Modal.module.scss ├── Navbar.module.scss ├── NotFound.module.scss ├── PDF.module.scss ├── Privacy.module.scss ├── ProgressCircle.module.scss ├── RecentlyUploadedFiles.module.scss ├── Search.module.scss ├── SignInButton.module.scss ├── Slack.module.scss ├── Spinner.module.scss ├── Support.module.scss ├── UploadDropOverlay.module.scss ├── UploadFile.module.scss ├── UserPage.module.scss ├── _colors.scss ├── _font.scss ├── _sizes.scss ├── _spinner.scss └── global.scss └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": ["react-optimized-image/plugin"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.DS_Store 2 | node_modules/ 3 | .vercel/ 4 | .next/ 5 | out/ 6 | .env 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [filein](https://filein.io) 2 | 3 | > The best way to share files 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/AccessToggle.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useCallback } from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons' 4 | import cx from 'classnames' 5 | 6 | import FileMeta from 'models/FileMeta' 7 | import editAccess from 'lib/editAccess' 8 | import useUpdate from 'hooks/useUpdate' 9 | 10 | import styles from 'styles/AccessToggle.module.scss' 11 | 12 | /** 13 | * If the `disabledMessage` is `undefined`, the toggle is not disabled. 14 | * If `null`, the toggle is disabled with no message. 15 | * If it's a `string`, the message will be displayed on hover. 16 | */ 17 | export interface AccessToggleProps { 18 | className?: string 19 | file: FileMeta 20 | disabledMessage?: string | null 21 | leftIndicator?: boolean 22 | } 23 | 24 | const AccessToggle = ({ className, file, disabledMessage, leftIndicator = false }: AccessToggleProps) => { 25 | const update = useUpdate() 26 | const isPublic = file.public 27 | 28 | const onChange = useCallback((event: ChangeEvent) => { 29 | editAccess(file, event.target.checked) 30 | update() 31 | }, [file, update]) 32 | 33 | return ( 34 | 53 | ) 54 | } 55 | 56 | export default AccessToggle 57 | -------------------------------------------------------------------------------- /components/AuthButton.tsx: -------------------------------------------------------------------------------- 1 | import useCurrentUser from 'hooks/useCurrentUser' 2 | import AuthDropdown from './AuthDropdown' 3 | import SignInButton from './SignInButton' 4 | 5 | export interface AuthButtonProps { 6 | className?: string 7 | } 8 | 9 | const AuthButton = ({ className }: AuthButtonProps) => { 10 | const currentUser = useCurrentUser() 11 | 12 | return currentUser 13 | ? 14 | : 15 | } 16 | 17 | export default AuthButton 18 | -------------------------------------------------------------------------------- /components/AuthDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react' 2 | 3 | import CurrentUser from 'models/CurrentUser' 4 | import Dropdown from './Dropdown' 5 | import AuthDropdownTrigger, { authDropdownTriggerClassName } from './AuthDropdownTrigger' 6 | import AuthDropdownContent, { authDropdownContentClassName } from './AuthDropdownContent' 7 | 8 | export interface AuthDropdownProps { 9 | className?: string 10 | currentUser: CurrentUser 11 | } 12 | 13 | const AuthDropdown = ({ className, currentUser }: AuthDropdownProps) => { 14 | const [isShowing, setIsShowing] = useState(false) 15 | 16 | const hide = useCallback(() => { 17 | setIsShowing(false) 18 | }, [setIsShowing]) 19 | 20 | return ( 21 | } 28 | > 29 | 30 | 31 | ) 32 | } 33 | 34 | export default AuthDropdown 35 | -------------------------------------------------------------------------------- /components/AuthDropdownContent.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useCallback } from 'react' 2 | import Link from 'next/link' 3 | import copy from 'copy-to-clipboard' 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 5 | import { faUser, faSignOutAlt, faShareSquare } from '@fortawesome/free-solid-svg-icons' 6 | import { toast } from 'react-toastify' 7 | import cx from 'classnames' 8 | 9 | import CurrentUser from 'models/CurrentUser' 10 | import EditUserName from './EditUserName' 11 | import SignOutButton from './SignOutButton' 12 | 13 | import styles from 'styles/AuthDropdownContent.module.scss' 14 | 15 | export interface AuthDropdownContentMyFilesActionProps { 16 | href?: string 17 | onClick: (() => void) | undefined 18 | } 19 | 20 | export interface AuthDropdownContentProps { 21 | currentUser: CurrentUser 22 | onClick?(): void 23 | } 24 | 25 | const AuthDropdownContentMyFilesAction = forwardRef(({ href, onClick }: AuthDropdownContentMyFilesActionProps, _ref) => ( 26 | 27 | 28 | My files 29 | 30 | )) 31 | 32 | const AuthDropdownContent = ({ currentUser, onClick }: AuthDropdownContentProps) => { 33 | const { data } = currentUser 34 | const apiKey = data?.apiKey 35 | 36 | const copyApiKey = useCallback(() => { 37 | copy(apiKey) 38 | toast.success('Copied your API key to clipboard') 39 | }, [apiKey]) 40 | 41 | return ( 42 | <> 43 | 44 | {data 45 | ? ( 46 | 47 | 48 | 49 | ) 50 | : 51 | } 52 | 56 | 57 | 58 | Sign out 59 | 60 | 61 | ) 62 | } 63 | 64 | export const authDropdownContentClassName = styles.root 65 | export default AuthDropdownContent 66 | -------------------------------------------------------------------------------- /components/AuthDropdownTrigger.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 2 | import { faChevronDown } from '@fortawesome/free-solid-svg-icons' 3 | 4 | import CurrentUser from 'models/CurrentUser' 5 | 6 | import styles from 'styles/AuthDropdownTrigger.module.scss' 7 | 8 | export interface AuthDropdownTriggerProps { 9 | currentUser: CurrentUser 10 | } 11 | 12 | const AuthDropdownTrigger = ({ currentUser }: AuthDropdownTriggerProps) => ( 13 | <> 14 |

15 | {currentUser.data?.name ?? currentUser.auth?.displayName} 16 |

17 | 18 | 19 | ) 20 | 21 | export const authDropdownTriggerClassName = styles.root 22 | export default AuthDropdownTrigger 23 | -------------------------------------------------------------------------------- /components/CommentRow.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { faChevronRight, faCrown } from '@fortawesome/free-solid-svg-icons' 4 | import cx from 'classnames' 5 | 6 | import CurrentUser from 'models/CurrentUser' 7 | import FileMeta from 'models/FileMeta' 8 | import Comment from 'models/Comment' 9 | import useUser from 'hooks/useUser' 10 | import useHideOverlays from 'hooks/useHideOverlays' 11 | import Spinner from './Spinner' 12 | 13 | import styles from 'styles/CommentRow.module.scss' 14 | 15 | export interface CommentRowProps { 16 | currentUser: CurrentUser | null 17 | file: FileMeta 18 | comments: Comment[] 19 | comment: Comment 20 | index: number 21 | } 22 | 23 | const CommentRow = ({ currentUser, file, comments, comment, index }: CommentRowProps) => { 24 | const fromSelf = (currentUser?.auth?.uid ?? currentUser?.data?.id) === comment.from 25 | const showName = !fromSelf && comments[index - 1]?.from !== comment.from 26 | 27 | const user = useUser(showName ? comment.from : undefined) 28 | const hideOverlays = useHideOverlays() 29 | 30 | return ( 31 |
35 | {showName && (user 36 | ? ( 37 | 38 | 39 | {user.name} 40 | 44 | 45 | 46 | ) 47 | : 48 | )} 49 |

{comment.body}

50 |
51 | ) 52 | } 53 | 54 | export default CommentRow 55 | -------------------------------------------------------------------------------- /components/Comments.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useCallback, useEffect, ChangeEvent, FormEvent } from 'react' 2 | import { toast } from 'react-toastify' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faComment } from '@fortawesome/free-solid-svg-icons' 5 | import cx from 'classnames' 6 | 7 | import FileMeta from 'models/FileMeta' 8 | import submitComment from 'lib/submitComment' 9 | import useSignIn from 'hooks/useSignIn' 10 | import useCurrentUser from 'hooks/useCurrentUser' 11 | import useComments from 'hooks/useComments' 12 | import CommentRow from './CommentRow' 13 | import Spinner from './Spinner' 14 | 15 | import styles from 'styles/Comments.module.scss' 16 | 17 | export interface CommentsProps { 18 | className?: string 19 | file: FileMeta 20 | } 21 | 22 | const Comments = ({ className, file }: CommentsProps) => { 23 | const commentsRef = useRef(null) 24 | 25 | const signIn = useSignIn() 26 | const currentUser = useCurrentUser() 27 | const comments = useComments(file) 28 | 29 | const [message, setMessage] = useState('') 30 | const [isLoading, setIsLoading] = useState(currentUser === undefined) 31 | 32 | const uid = currentUser?.auth?.uid ?? currentUser?.data?.id 33 | 34 | const onSubmit = useCallback(async (event: FormEvent) => { 35 | event.preventDefault() 36 | 37 | if (!uid) { 38 | setIsLoading(true) 39 | 40 | signIn() 41 | .catch(({ message }) => toast.error(message)) 42 | .finally(() => setIsLoading(false)) 43 | 44 | return 45 | } 46 | 47 | submitComment(uid, file, message) 48 | .catch(({ message }) => toast.error(message)) 49 | 50 | setMessage('') 51 | }, [uid, file, message, signIn, setMessage, setIsLoading]) 52 | 53 | const onChange = useCallback((event: ChangeEvent) => { 54 | setMessage(event.target.value) 55 | }, [setMessage]) 56 | 57 | useEffect(() => { 58 | setIsLoading(currentUser === undefined) 59 | }, [currentUser, setIsLoading]) 60 | 61 | useEffect(() => { 62 | const { current } = commentsRef 63 | 64 | if (current) 65 | current.scrollTop = current.scrollHeight 66 | }, [comments, commentsRef]) 67 | 68 | return ( 69 |
70 |
71 | {comments && currentUser !== undefined 72 | ? comments.length 73 | ? comments.map((comment, index) => ( 74 | 82 | )) 83 | :

Be the first one to comment

84 | : 85 | } 86 |
87 |
88 | 103 | 104 | 113 | 114 |
115 | ) 116 | } 117 | 118 | export default Comments 119 | -------------------------------------------------------------------------------- /components/CurrentFile.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react' 2 | import { useRecoilState } from 'recoil' 3 | import { useRouter } from 'next/router' 4 | import Link from 'next/link' 5 | import { saveAs } from 'file-saver' 6 | import copy from 'copy-to-clipboard' 7 | import { toast } from 'react-toastify' 8 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 9 | import { faChevronRight, faDownload, faLink, faTimes, faTrash } from '@fortawesome/free-solid-svg-icons' 10 | import cx from 'classnames' 11 | 12 | import getFileUrl from 'lib/getFileUrl' 13 | import _deleteFile from 'lib/deleteFile' 14 | import currentFileState from 'state/currentFile' 15 | import useCurrentUser from 'hooks/useCurrentUser' 16 | import useUser from 'hooks/useUser' 17 | import useHideOverlays from 'hooks/useHideOverlays' 18 | import Modal from './Modal' 19 | import Title from './Title' 20 | import AccessToggle from './AccessToggle' 21 | import FilePreview from './FilePreview' 22 | import EditFileName from './EditFileName' 23 | import Spinner from './Spinner' 24 | import Comments from './Comments' 25 | 26 | import styles from 'styles/CurrentFile.module.scss' 27 | 28 | const CurrentFile = () => { 29 | const [file, setFile] = useRecoilState(currentFileState) 30 | const hideOverlays = useHideOverlays() 31 | 32 | const path = useRouter().asPath 33 | const currentUser = useCurrentUser() 34 | const user = useUser(file?.owner) 35 | 36 | const id = file?.id 37 | const url = file && getFileUrl(file) 38 | const isOwner = currentUser?.auth 39 | ? currentUser.auth.uid === file?.owner 40 | : false 41 | 42 | const hide = useCallback(() => { 43 | hideOverlays() 44 | history.replaceState({}, '', path) 45 | }, [path, hideOverlays]) 46 | 47 | const setIsShowing = useCallback((isShowing: boolean) => { 48 | if (!isShowing) 49 | hide() 50 | }, [hide]) 51 | 52 | const download = useCallback(() => { 53 | if (file) 54 | saveAs(getFileUrl(file, true), file.name) 55 | }, [file]) 56 | 57 | const copyLink = useCallback(() => { 58 | if (!url) 59 | return 60 | 61 | copy(url) 62 | toast.success('Copied file to clipboard') 63 | }, [url]) 64 | 65 | const deleteFile = useCallback(() => { 66 | if (!(file && window.confirm('Are you sure? You cannot restore a deleted file.'))) 67 | return 68 | 69 | _deleteFile(file) 70 | .catch(({ message }) => toast.error(message)) 71 | 72 | hide() 73 | }, [file, hide]) 74 | 75 | useEffect(() => { 76 | if (id) 77 | history.replaceState({}, '', `/${id}`) 78 | }, [id]) 79 | 80 | return ( 81 | 82 | {file && {file.name} - filein} 83 |
84 |

{file?.name}

85 | {isOwner && } 86 | 89 |
90 |
91 | {file && ( 92 | <> 93 | 94 |
95 |
96 |
97 | {isOwner 98 | ? 99 | :

{file.name}

100 | } 101 |

102 | Uploaded by {file.owner 103 | ? user 104 | ? ( 105 | 106 | 107 | {user.name} 108 | 112 | 113 | 114 | ) 115 | : 116 | : anonymous 117 | } 118 |

119 |
120 |
121 | 128 | 135 | {isOwner && ( 136 | 143 | )} 144 |
145 |
146 | 147 |
148 | 149 | )} 150 |
151 |
152 | ) 153 | } 154 | 155 | export default CurrentFile 156 | -------------------------------------------------------------------------------- /components/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, SetStateAction, useCallback, useEffect, useRef } from 'react' 2 | import cx from 'classnames' 3 | 4 | import styles from 'styles/Dropdown.module.scss' 5 | 6 | export interface DropdownProps { 7 | isShowing: boolean 8 | setIsShowing(isShowing: SetStateAction): void 9 | rootClassName?: string 10 | triggerClassName?: string 11 | contentClassName?: string 12 | trigger: ReactNode 13 | children: ReactNode 14 | } 15 | 16 | const Dropdown = ({ isShowing, setIsShowing, rootClassName, triggerClassName, contentClassName, trigger, children }: DropdownProps) => { 17 | const triggerRef = useRef(null) 18 | const contentRef = useRef(null) 19 | 20 | const onClick = useCallback((event: MouseEvent) => { 21 | const trigger = triggerRef.current 22 | const content = contentRef.current 23 | const target = event.target as Node 24 | 25 | if (!(trigger && content)) 26 | return 27 | 28 | if (trigger === target || trigger.contains(target)) 29 | return setIsShowing(isShowing => !isShowing) 30 | 31 | if (content === target || content.contains(target)) 32 | return 33 | 34 | setIsShowing(false) 35 | }, [triggerRef, contentRef, setIsShowing]) 36 | 37 | useEffect(() => { 38 | const { body } = document 39 | 40 | body.addEventListener('click', onClick) 41 | return () => body.removeEventListener('click', onClick) 42 | }, [onClick]) 43 | 44 | return ( 45 |
46 | 49 |
50 | {children} 51 |
52 |
53 | ) 54 | } 55 | 56 | export default Dropdown 57 | -------------------------------------------------------------------------------- /components/EditFileName.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useCallback, FormEvent, ChangeEvent } from 'react' 2 | import { toast } from 'react-toastify' 3 | 4 | import FileMeta from 'models/FileMeta' 5 | import normalizeExtension from 'lib/normalizeExtension' 6 | import editFileName from 'lib/editFileName' 7 | 8 | import styles from 'styles/EditFileName.module.scss' 9 | 10 | export interface EditFileNameProps { 11 | className?: string 12 | file: FileMeta 13 | onEdit?(file: FileMeta): void 14 | disabledMessage?: string 15 | } 16 | 17 | const EditFileName = ({ className, file, onEdit, disabledMessage }: EditFileNameProps) => { 18 | const input = useRef(null) 19 | const [name, setName] = useState(file.name) 20 | 21 | const onSubmit = useCallback((event: FormEvent) => { 22 | event.preventDefault() 23 | 24 | if (name) 25 | input.current?.blur() 26 | }, [name, input]) 27 | 28 | const onBlur = useCallback(async () => { 29 | if (!name) 30 | return setName(file.name) 31 | 32 | const newName = normalizeExtension(file.name, name) 33 | 34 | if (file.name === newName) 35 | return setName(newName) 36 | 37 | try { 38 | await editFileName(file, newName) 39 | 40 | setName(newName) 41 | onEdit?.({ ...file, name: newName }) 42 | 43 | toast.success('Updated name') 44 | } catch ({ message }) { 45 | toast.error(message) 46 | } 47 | }, [file, name, setName, onEdit]) 48 | 49 | const onChange = useCallback((event: ChangeEvent) => { 50 | setName(event.target.value) 51 | }, [setName]) 52 | 53 | return ( 54 |
60 | 69 |
70 | ) 71 | } 72 | 73 | export default EditFileName 74 | -------------------------------------------------------------------------------- /components/EditUserName.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useCallback, FormEvent, ChangeEvent } from 'react' 2 | import { useSetRecoilState } from 'recoil' 3 | import { toast } from 'react-toastify' 4 | 5 | import CurrentUser from 'models/CurrentUser' 6 | import editUserName from 'lib/editUserName' 7 | import currentUserState from 'state/currentUser' 8 | 9 | import styles from 'styles/EditUserName.module.scss' 10 | 11 | export interface EditUserNameProps { 12 | className?: string 13 | user: CurrentUser 14 | } 15 | 16 | const EditUserName = ({ className, user }: EditUserNameProps) => { 17 | const setCurrentUser = useSetRecoilState(currentUserState) 18 | 19 | const uid = user.auth?.uid ?? user.data?.id 20 | const currentName = user.data?.name ?? user.auth?.displayName 21 | 22 | const input = useRef(null) 23 | const [name, setName] = useState(currentName) 24 | 25 | const onSubmit = useCallback((event: FormEvent) => { 26 | event.preventDefault() 27 | 28 | if (name) 29 | input.current?.blur() 30 | }, [name, input]) 31 | 32 | const onBlur = useCallback(async () => { 33 | if (!(uid && name) || currentName === name) 34 | return 35 | 36 | try { 37 | await editUserName(uid, name) 38 | 39 | setCurrentUser(currentUser => ({ 40 | ...currentUser, 41 | data: { ...currentUser.data, name } 42 | })) 43 | 44 | toast.success('Updated name') 45 | } catch ({ message }) { 46 | toast.error(message) 47 | } 48 | }, [uid, currentName, name, setCurrentUser]) 49 | 50 | const onChange = useCallback((event: ChangeEvent) => { 51 | setName(event.target.value) 52 | }, [setName]) 53 | 54 | return ( 55 |
56 | 64 |
65 | ) 66 | } 67 | 68 | export default EditUserName 69 | -------------------------------------------------------------------------------- /components/FileCell.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useCallback } from 'react' 2 | import { useSetRecoilState } from 'recoil' 3 | import dynamic from 'next/dynamic' 4 | import Link from 'next/link' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | import { faChevronRight, faComment, faVideo } from '@fortawesome/free-solid-svg-icons' 7 | import cx from 'classnames' 8 | 9 | import FileMeta from 'models/FileMeta' 10 | import FileType from 'models/FileType' 11 | import getFileType from 'lib/getFileType' 12 | import getFileUrl from 'lib/getFileUrl' 13 | import getFileIcon from 'lib/getFileIcon' 14 | import currentFileState from 'state/currentFile' 15 | import useUser from 'hooks/useUser' 16 | import Spinner from './Spinner' 17 | 18 | import styles from 'styles/FileCell.module.scss' 19 | 20 | const DIMENSION = 300 21 | const MAX_PREVIEW_SIZE = 100 * 1024 * 1024 // 100 MB 22 | 23 | interface FileCellIconProps { 24 | file: FileMeta 25 | } 26 | 27 | interface FileCellContentProps { 28 | file: FileMeta 29 | type: FileType 30 | isFallback: boolean 31 | } 32 | 33 | export interface FileCellProps { 34 | file: FileMeta 35 | owner?: boolean 36 | } 37 | 38 | const PDF = dynamic(() => import('./PDF'), { ssr: false }) 39 | 40 | const stopPropagation = (event: MouseEvent) => 41 | event.stopPropagation() 42 | 43 | const FileCellIcon = ({ file }: FileCellIconProps) => ( 44 | 45 | ) 46 | 47 | const FileCellContent = ({ file, type, isFallback }: FileCellContentProps) => { 48 | if (isFallback) 49 | return 50 | 51 | switch (type) { 52 | case FileType.Image: 53 | return ( 54 | {file.name} 62 | ) 63 | case FileType.Video: 64 | return ( 65 | <> 66 | 67 | 71 | 72 | ) 73 | case FileType.PDF: 74 | return 75 | case FileType.Audio: 76 | case FileType.Other: 77 | return 78 | } 79 | } 80 | 81 | const FileCell = ({ file, owner = false }: FileCellProps) => { 82 | const setCurrentFile = useSetRecoilState(currentFileState) 83 | const user = useUser(owner ? file.owner : null) 84 | 85 | const type = getFileType(file) 86 | const isFallback = file.size > MAX_PREVIEW_SIZE 87 | 88 | const onClick = useCallback(() => { 89 | setCurrentFile(file) 90 | }, [file, setCurrentFile]) 91 | 92 | return ( 93 |
101 | 102 | {owner && file.owner && ( 103 |
104 | {user 105 | ? ( 106 | 107 | 108 | {user.name} 109 | 110 | 111 | 112 | ) 113 | : 114 | } 115 |
116 | )} 117 |
118 |

{file.name}

119 |
120 | 121 |

{file.comments}

122 |
123 |
124 |
125 | ) 126 | } 127 | 128 | export default FileCell 129 | -------------------------------------------------------------------------------- /components/FileGrid.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import cx from 'classnames' 3 | 4 | import styles from 'styles/FileGrid.module.scss' 5 | 6 | export interface FilesProps { 7 | className?: string 8 | children?: ReactNode 9 | } 10 | 11 | const FileGrid = ({ className, children }: FilesProps) => ( 12 |
{children}
13 | ) 14 | 15 | export default FileGrid 16 | -------------------------------------------------------------------------------- /components/FileHead.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | 3 | import FileMeta from 'models/FileMeta' 4 | import FileType from 'models/FileType' 5 | import getFileType from 'lib/getFileType' 6 | import getFileUrl from 'lib/getFileUrl' 7 | import { MAX_FILE_PREVIEW_SIZE } from './FilePreview' 8 | 9 | export interface FileHeadProps { 10 | file: FileMeta 11 | } 12 | 13 | interface FilePreviewHeadProps { 14 | file: FileMeta 15 | type: FileType 16 | url: string 17 | } 18 | 19 | const getPreloadAs = (file: FileMeta, type: FileType) => { 20 | if (file.size > MAX_FILE_PREVIEW_SIZE) 21 | return null 22 | 23 | switch (type) { 24 | case FileType.Image: 25 | return 'image' 26 | case FileType.Video: 27 | return 'video' 28 | case FileType.Audio: 29 | return 'audio' 30 | case FileType.PDF: 31 | return 'fetch' 32 | case FileType.Other: 33 | return null 34 | } 35 | } 36 | 37 | const FilePreviewHead = ({ file, type, url }: FilePreviewHeadProps) => { 38 | switch (type) { 39 | case FileType.Image: 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ) 50 | case FileType.Video: 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | ) 58 | case FileType.Audio: 59 | return ( 60 | 61 | 62 | 63 | 64 | 65 | ) 66 | default: 67 | return null 68 | } 69 | } 70 | 71 | const FileHead = ({ file }: FileHeadProps) => { 72 | const type = getFileType(file) 73 | const url = getFileUrl(file, true) 74 | const preloadAs = getPreloadAs(file, type) 75 | 76 | return ( 77 | <> 78 | {preloadAs && ( 79 | 80 | 81 | 82 | )} 83 | 84 | 85 | ) 86 | } 87 | 88 | export default FileHead 89 | -------------------------------------------------------------------------------- /components/FilePreview.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import dynamic from 'next/dynamic' 3 | import copy from 'copy-to-clipboard' 4 | import { toast } from 'react-toastify' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | import { faShareSquare, faVideo, faVolumeUp } from '@fortawesome/free-solid-svg-icons' 7 | import cx from 'classnames' 8 | 9 | import FileMeta from 'models/FileMeta' 10 | import FileType from 'models/FileType' 11 | import getFileType from 'lib/getFileType' 12 | import getFileUrl from 'lib/getFileUrl' 13 | import getFileIcon from 'lib/getFileIcon' 14 | 15 | import styles from 'styles/FilePreview.module.scss' 16 | 17 | export const MAX_FILE_PREVIEW_SIZE = 150 * 1024 * 1024 // 150 MB 18 | export const CONTENT_CLASS_NAME = 'file-preview-content' 19 | 20 | interface FilePreviewIconProps { 21 | file: FileMeta 22 | } 23 | 24 | interface FilePreviewContentProps { 25 | file: FileMeta 26 | type: FileType 27 | isFallback: boolean 28 | } 29 | 30 | export interface FilePreviewProps { 31 | className?: string 32 | file: FileMeta 33 | } 34 | 35 | const PDF = dynamic(() => import('./PDF'), { ssr: false }) 36 | 37 | const FilePreviewIcon = ({ file }: FilePreviewIconProps) => ( 38 | 39 | ) 40 | 41 | const FilePreviewContent = ({ file, type, isFallback }: FilePreviewContentProps) => { 42 | if (isFallback) 43 | return 44 | 45 | switch (type) { 46 | case FileType.Image: 47 | return {file.name} 48 | case FileType.Video: 49 | return ( 50 | 54 | ) 55 | case FileType.Audio: 56 | return ( 57 | <> 58 | 59 | 63 | 64 | ) 65 | case FileType.PDF: 66 | return 67 | case FileType.Other: 68 | return 69 | } 70 | } 71 | 72 | const FilePreview = ({ className, file }: FilePreviewProps) => { 73 | const type = getFileType(file) 74 | const isFallback = file.size > MAX_FILE_PREVIEW_SIZE 75 | 76 | const onClick = useCallback(() => { 77 | copy(`https://filein.io/${file.id}`) 78 | toast.success('Copied link to clipboard') 79 | }, [file.id]) 80 | 81 | return ( 82 |
89 | 90 | 91 | 92 | 93 |
94 | ) 95 | } 96 | 97 | export default FilePreview 98 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { faGithub } from '@fortawesome/free-brands-svg-icons' 2 | import { faCode } from '@fortawesome/free-solid-svg-icons' 3 | import cx from 'classnames' 4 | 5 | import Link from './FooterLink' 6 | import SlackInstallButton from './SlackInstallButton' 7 | 8 | import styles from 'styles/Footer.module.scss' 9 | 10 | export interface FooterProps { 11 | className?: string 12 | } 13 | 14 | const Footer = ({ className }: FooterProps) => ( 15 |
16 |
17 | GitHub 18 | API 19 |

20 | Copyright © 2020 filein. All rights reserved. 21 |

22 | 23 |
24 |
25 | ) 26 | 27 | export default Footer 28 | -------------------------------------------------------------------------------- /components/FooterLink.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { IconDefinition } from '@fortawesome/free-solid-svg-icons' 4 | 5 | import styles from 'styles/FooterLink.module.scss' 6 | 7 | export interface FooterLinkProps { 8 | href: string 9 | icon: IconDefinition 10 | children?: ReactNode 11 | } 12 | 13 | const FooterLink = ({ href, icon, children }: FooterLinkProps) => ( 14 | 20 | 21 | {children} 22 | 23 | ) 24 | 25 | export default FooterLink 26 | -------------------------------------------------------------------------------- /components/Gradient.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import cx from 'classnames' 3 | 4 | import styles from 'styles/Gradient.module.scss' 5 | 6 | export interface GradientProps { 7 | className?: string 8 | children?: ReactNode 9 | } 10 | 11 | const Gradient = ({ className, children }: GradientProps) => ( 12 |
13 |
14 |
15 | {children} 16 |
17 |
18 | ) 19 | 20 | export default Gradient 21 | -------------------------------------------------------------------------------- /components/Head.tsx: -------------------------------------------------------------------------------- 1 | import NextHead from 'next/head' 2 | 3 | export interface HeadProps { 4 | url: string 5 | title: string 6 | description: string 7 | storagePreconnect?: boolean 8 | } 9 | 10 | const Head = ({ url, title, description, storagePreconnect = true }: HeadProps) => ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {title} 20 | 21 | 22 | {storagePreconnect && } 23 | 24 | ) 25 | 26 | export default Head 27 | -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useRef, useCallback, useEffect } from 'react' 2 | import cx from 'classnames' 3 | 4 | import clipBody from 'lib/clipBody' 5 | 6 | import styles from 'styles/Modal.module.scss' 7 | 8 | export interface ModalProps { 9 | className?: string 10 | isShowing: boolean 11 | setIsShowing(isShowing: boolean): void 12 | children?: ReactNode 13 | } 14 | 15 | const Modal = ({ className, isShowing, setIsShowing, children }: ModalProps) => { 16 | const root = useRef(null) 17 | const content = useRef(null) 18 | 19 | const onClick = useCallback(({ target }: MouseEvent) => { 20 | const { current } = content 21 | 22 | if (current && !(target === current || current.contains(target as Node))) 23 | setIsShowing(false) 24 | }, [content, setIsShowing]) 25 | 26 | useEffect(() => { 27 | const { current } = root 28 | 29 | if (!current) 30 | return 31 | 32 | current.addEventListener('click', onClick) 33 | return () => current.removeEventListener('click', onClick) 34 | }, [root, onClick]) 35 | 36 | useEffect(() => { 37 | clipBody(isShowing) 38 | }, [isShowing]) 39 | 40 | return ( 41 |
47 |
48 | {children} 49 |
50 |
51 | ) 52 | } 53 | 54 | export default Modal 55 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from 'react' 2 | import Link from 'next/link' 3 | import ProgressBar from 'nextjs-progressbar' 4 | import { Svg } from 'react-optimized-image' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | import { faUpload } from '@fortawesome/free-solid-svg-icons' 7 | import cx from 'classnames' 8 | 9 | import UploadButton from './UploadButton' 10 | import AuthButton from './AuthButton' 11 | 12 | import icon from 'images/icon.svg' 13 | 14 | import styles from 'styles/Navbar.module.scss' 15 | 16 | const Navbar = () => { 17 | const [isActive, setIsActive] = useState(false) 18 | 19 | const onScroll = useCallback(() => { 20 | setIsActive(document.documentElement.scrollTop > 0) 21 | }, [setIsActive]) 22 | 23 | useEffect(() => { 24 | onScroll() 25 | window.addEventListener('scroll', onScroll) 26 | 27 | return () => window.removeEventListener('scroll', onScroll) 28 | }, [onScroll]) 29 | 30 | return ( 31 |
32 | 33 | 46 |
47 | ) 48 | } 49 | 50 | export default Navbar 51 | -------------------------------------------------------------------------------- /components/PDF.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Document, Page } from 'react-pdf/dist/esm/entry.webpack' 3 | import { toast } from 'react-toastify' 4 | import cx from 'classnames' 5 | 6 | import Spinner from './Spinner' 7 | 8 | import styles from 'styles/PDF.module.scss' 9 | 10 | export interface PDFProps { 11 | className?: string 12 | url: Blob | string 13 | firstPageOnly?: boolean 14 | } 15 | 16 | const PDF = ({ className, url, firstPageOnly = false }: PDFProps) => { 17 | const [pages, setPages] = useState(null) 18 | const [isLoading, setIsLoading] = useState(true) 19 | 20 | return ( 21 | } 25 | onLoadSuccess={({ numPages }) => { 26 | if (!firstPageOnly) 27 | setPages(Array.from(new Array(numPages).keys())) 28 | 29 | setIsLoading(false) 30 | }} 31 | onLoadError={({ message }) => { 32 | setIsLoading(false) 33 | toast.error(message) 34 | }} 35 | > 36 | {firstPageOnly 37 | ? 38 | : pages?.map(page => ( 39 | 40 | )) 41 | } 42 | 43 | ) 44 | } 45 | 46 | export default PDF 47 | -------------------------------------------------------------------------------- /components/ProgressCircle.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react' 2 | import cx from 'classnames' 3 | 4 | import styles from 'styles/ProgressCircle.module.scss' 5 | 6 | export interface ProgressCircleProps extends SVGProps { 7 | className?: string 8 | value: number 9 | } 10 | 11 | const ProgressCircle = ({ className, value, ...props }: ProgressCircleProps) => { 12 | const determinant = value * 2.64 13 | 14 | return ( 15 | 16 | 17 | 24 | 31 | {Math.floor(value)}% 32 | 33 | 34 | ) 35 | } 36 | 37 | export default ProgressCircle 38 | -------------------------------------------------------------------------------- /components/RecentlyUploadedFiles.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { useRouter } from 'next/router' 3 | import cx from 'classnames' 4 | 5 | import FileMeta from 'models/FileMeta' 6 | import getFilePredicate from 'lib/getFilePredicate' 7 | import useRecentlyUploadedFiles from 'hooks/useRecentlyUploadedFiles' 8 | import Search from './Search' 9 | import FileGrid from './FileGrid' 10 | import FileCell from './FileCell' 11 | 12 | import styles from 'styles/RecentlyUploadedFiles.module.scss' 13 | 14 | export interface RecentlyUploadedFilesProps { 15 | className?: string 16 | files: FileMeta[] 17 | } 18 | 19 | const RecentlyUploadedFiles = ({ className, files: _files }: RecentlyUploadedFilesProps) => { 20 | const router = useRouter() 21 | const files = useRecentlyUploadedFiles() ?? _files 22 | const query = (router.query.q ?? '') as string 23 | 24 | const setQuery = useCallback((query: string) => { 25 | router.replace( 26 | `/${query ? `?q=${encodeURIComponent(query)}` : ''}`, 27 | undefined, 28 | { shallow: true } 29 | ) 30 | }, [router]) 31 | 32 | return ( 33 |
34 | 39 | 40 | {files?.filter(getFilePredicate(query)).map(file => ( 41 | 42 | ))} 43 | 44 |
45 | ) 46 | } 47 | 48 | export default RecentlyUploadedFiles 49 | -------------------------------------------------------------------------------- /components/Search.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useCallback } from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { faSearch } from '@fortawesome/free-solid-svg-icons' 4 | import cx from 'classnames' 5 | 6 | import styles from 'styles/Search.module.scss' 7 | 8 | export interface SearchProps { 9 | className?: string 10 | placeholder: string 11 | value: string 12 | setValue(value: string): void 13 | } 14 | 15 | const Search = ({ className, placeholder, value, setValue }: SearchProps) => { 16 | const onChange = useCallback((event: ChangeEvent) => { 17 | setValue(event.target.value) 18 | }, [setValue]) 19 | 20 | return ( 21 |
22 | 28 | 29 |
30 | ) 31 | } 32 | 33 | export default Search 34 | -------------------------------------------------------------------------------- /components/SignInButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react' 2 | import { toast } from 'react-toastify' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faGoogle } from '@fortawesome/free-brands-svg-icons' 5 | import cx from 'classnames' 6 | 7 | import useSignIn from 'hooks/useSignIn' 8 | import Spinner from './Spinner' 9 | 10 | import styles from 'styles/SignInButton.module.scss' 11 | 12 | export interface SignInButtonProps { 13 | className?: string 14 | disabled: boolean 15 | } 16 | 17 | const SignInButton = ({ className, disabled }: SignInButtonProps) => { 18 | const signIn = useSignIn() 19 | const [isLoading, setIsLoading] = useState(false) 20 | 21 | const onClick = useCallback(async () => { 22 | if (disabled || isLoading) 23 | return 24 | 25 | setIsLoading(true) 26 | 27 | try { 28 | if (!await signIn()) 29 | setIsLoading(false) 30 | } catch ({ message }) { 31 | setIsLoading(false) 32 | toast.error(message) 33 | } 34 | }, [disabled, isLoading, signIn, setIsLoading]) 35 | 36 | return ( 37 | 52 | ) 53 | } 54 | 55 | export default SignInButton 56 | -------------------------------------------------------------------------------- /components/SignOutButton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState, useCallback } from 'react' 2 | import { toast } from 'react-toastify' 3 | 4 | import signOut from 'lib/signOut' 5 | 6 | export interface SignOutButtonProps { 7 | className?: string 8 | onClick?(): void 9 | children?: ReactNode 10 | } 11 | 12 | const SignOutButton = ({ className, onClick: _onClick, children }: SignOutButtonProps) => { 13 | const [isLoading, setIsLoading] = useState(false) 14 | 15 | const onClick = useCallback(async () => { 16 | if (isLoading) 17 | return 18 | 19 | try { 20 | _onClick() 21 | 22 | setIsLoading(true) 23 | await signOut() 24 | } catch ({ message }) { 25 | setIsLoading(false) 26 | toast.error(message) 27 | } 28 | }, [isLoading, setIsLoading, _onClick]) 29 | 30 | return ( 31 | 34 | ) 35 | } 36 | 37 | export default SignOutButton 38 | -------------------------------------------------------------------------------- /components/SlackInstallButton.tsx: -------------------------------------------------------------------------------- 1 | export interface SlackInstallButtonProps { 2 | className?: string 3 | } 4 | 5 | const SlackInstallButton = ({ className }: SlackInstallButtonProps) => ( 6 | 7 | Add to Slack 14 | 15 | ) 16 | 17 | export default SlackInstallButton 18 | -------------------------------------------------------------------------------- /components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'classnames' 2 | 3 | import styles from 'styles/Spinner.module.scss' 4 | 5 | export interface SpinnerProps { 6 | className: string 7 | } 8 | 9 | const Spinner = ({ className }: SpinnerProps) => ( 10 | 11 | ) 12 | 13 | export default Spinner 14 | -------------------------------------------------------------------------------- /components/Title.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import Head from 'next/head' 3 | 4 | export interface TitleProps { 5 | children: ReactNode 6 | } 7 | 8 | const Title = ({ children }: TitleProps) => ( 9 | 10 | {children} 11 | 12 | ) 13 | 14 | export default Title 15 | -------------------------------------------------------------------------------- /components/UploadButton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useCallback } from 'react' 2 | import { useSetRecoilState } from 'recoil' 3 | import { useDropzone } from 'react-dropzone' 4 | 5 | import uploadFileState from 'state/uploadFile' 6 | 7 | export interface UploadButtonProps { 8 | className?: string 9 | children?: ReactNode 10 | } 11 | 12 | const UploadButton = ({ className, children }: UploadButtonProps) => { 13 | const uploadFile = useSetRecoilState(uploadFileState) 14 | 15 | const onDrop = useCallback((files: File[]) => { 16 | const file = files[0] 17 | 18 | if (file) 19 | uploadFile(file) 20 | }, [uploadFile]) 21 | 22 | const { getRootProps, getInputProps } = useDropzone({ 23 | onDrop, 24 | noDrag: true, 25 | multiple: false 26 | }) 27 | 28 | return ( 29 |
30 | 31 | {children} 32 |
33 | ) 34 | } 35 | 36 | export default UploadButton 37 | -------------------------------------------------------------------------------- /components/UploadDrop.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useCallback } from 'react' 2 | import { useSetRecoilState } from 'recoil' 3 | import { useDropzone } from 'react-dropzone' 4 | 5 | import uploadFileState from 'state/uploadFile' 6 | import UploadDropOverlay from './UploadDropOverlay' 7 | 8 | export interface UploadDropProps { 9 | children?: ReactNode 10 | } 11 | 12 | const UploadDrop = ({ children }: UploadDropProps) => { 13 | const setFile = useSetRecoilState(uploadFileState) 14 | 15 | const onDrop = useCallback((files: File[]) => { 16 | const file = files[0] 17 | if (file) setFile(file) 18 | }, [setFile]) 19 | 20 | const onDragEnter = useCallback(() => { 21 | setFile(null) 22 | }, [setFile]) 23 | 24 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ 25 | onDrop, 26 | onDragEnter, 27 | noClick: true, 28 | multiple: false 29 | }) 30 | 31 | return ( 32 |
33 | 34 | 35 | {children} 36 |
37 | ) 38 | } 39 | 40 | export default UploadDrop 41 | -------------------------------------------------------------------------------- /components/UploadDropOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { faUpload } from '@fortawesome/free-solid-svg-icons' 4 | 5 | import clipBody from 'lib/clipBody' 6 | 7 | import styles from 'styles/UploadDropOverlay.module.scss' 8 | 9 | export interface UploadDropOverlayProps { 10 | active: boolean 11 | } 12 | 13 | const UploadDropOverlay = ({ active }: UploadDropOverlayProps) => { 14 | useEffect(() => { 15 | clipBody(active) 16 | }, [active]) 17 | 18 | return ( 19 |
20 | 21 |

Drop file

22 |
23 | ) 24 | } 25 | 26 | export default UploadDropOverlay 27 | -------------------------------------------------------------------------------- /components/UploadFile.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react' 2 | import { useRecoilValue } from 'recoil' 3 | import { useRouter } from 'next/router' 4 | import { saveAs } from 'file-saver' 5 | import copy from 'copy-to-clipboard' 6 | import { toast } from 'react-toastify' 7 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 8 | import { faDownload, faLink, faTimes, faTrash } from '@fortawesome/free-solid-svg-icons' 9 | import cx from 'classnames' 10 | 11 | import FileMeta from 'models/FileMeta' 12 | import uploadFile from 'lib/uploadFile' 13 | import _deleteFile from 'lib/deleteFile' 14 | import getFileUrl from 'lib/getFileUrl' 15 | import uploadFileState from 'state/uploadFile' 16 | import useCurrentUser from 'hooks/useCurrentUser' 17 | import useHideOverlays from 'hooks/useHideOverlays' 18 | import Modal from './Modal' 19 | import Title from './Title' 20 | import AccessToggle from './AccessToggle' 21 | import ProgressCircle from './ProgressCircle' 22 | import FilePreview from './FilePreview' 23 | import EditFileName from './EditFileName' 24 | import Comments from './Comments' 25 | 26 | import styles from 'styles/UploadFile.module.scss' 27 | 28 | const UploadFile = () => { 29 | const path = useRouter().asPath 30 | const currentUser = useCurrentUser() 31 | const hideOverlays = useHideOverlays() 32 | 33 | const file = useRecoilValue(uploadFileState) 34 | const [fileMeta, setFileMeta] = useState(null) 35 | const [progress, setProgress] = useState(0) 36 | 37 | const isComplete = progress === 100 38 | const uid = currentUser && currentUser.auth?.uid 39 | const id = fileMeta?.id 40 | const url = fileMeta && getFileUrl(fileMeta) 41 | const name = fileMeta?.name ?? file?.name 42 | 43 | const hide = useCallback(() => { 44 | hideOverlays() 45 | history.replaceState({}, '', path) 46 | }, [path, hideOverlays]) 47 | 48 | const setIsShowing = useCallback((isShowing: boolean) => { 49 | if (!isShowing) 50 | hide() 51 | }, [hide]) 52 | 53 | const download = useCallback(() => { 54 | if (file && fileMeta) 55 | saveAs(file, fileMeta.name) 56 | }, [file, fileMeta]) 57 | 58 | const copyLink = useCallback(() => { 59 | if (!url) 60 | return 61 | 62 | copy(url) 63 | toast.success('Copied file to clipboard') 64 | }, [url]) 65 | 66 | const deleteFile = useCallback(() => { 67 | if (!(fileMeta && window.confirm('Are you sure? You cannot restore a deleted file.'))) 68 | return 69 | 70 | _deleteFile(fileMeta) 71 | .catch(({ message }) => toast.error(message)) 72 | 73 | hide() 74 | }, [fileMeta, hide]) 75 | 76 | useEffect(() => { // Reset 77 | if (file) 78 | return 79 | 80 | setFileMeta(null) 81 | setProgress(0) 82 | }, [file, setFileMeta, setProgress]) 83 | 84 | useEffect(() => { // Upload 85 | if (!file || uid === undefined) 86 | return 87 | 88 | uploadFile(file, uid, setProgress) 89 | .then(setFileMeta) 90 | .catch(({ message }) => toast.error(message)) 91 | }, [uid, file, setFileMeta, setProgress]) 92 | 93 | useEffect(() => { 94 | if (id) 95 | history.replaceState({}, '', `/${id}`) 96 | }, [id]) 97 | 98 | useEffect(copyLink, [copyLink]) 99 | 100 | return ( 101 | 102 | {name && {name} - filein} 103 |
104 |

{name}

105 | {fileMeta && ( 106 | 111 | )} 112 | 115 |
116 |
117 | 122 |
123 | {fileMeta && ( 124 | <> 125 | 126 |
127 |
128 | 134 |
135 | 142 | 149 | {uid && ( 150 | 157 | )} 158 |
159 |
160 | 161 |
162 | 163 | )} 164 |
165 |
166 |
167 | ) 168 | } 169 | 170 | export default UploadFile 171 | -------------------------------------------------------------------------------- /docs/privacy.mdx: -------------------------------------------------------------------------------- 1 | Last updated February 25, 2020 2 | 3 | Thank you for choosing to be part of our community at filein ("Company", "we", "us", or "our"). We are committed to protecting your personal information and your right to privacy. If you have any questions or concerns about our notice, or our practices with regards to your personal information, please contact us at kenmueller0@gmail.com. 4 | 5 | When you visit our mobile application, and use our services, you trust us with your personal information. We take your privacy very seriously. In this privacy notice, we seek to explain to you in the clearest way possible what information we collect, how we use it and what rights you have in relation to it. We hope you take some time to read through it carefully, as it is important. If there are any terms in this privacy notice that you do not agree with, please discontinue use of our Apps and our services. 6 | 7 | This privacy notice applies to all information collected through our mobile application, ("Apps"), and/or any related services, sales, marketing or events (we refer to them collectively in this privacy notice as the "Services"). 8 | 9 | Please read this privacy notice carefully as it will help you make informed decisions about sharing your personal information with us. 10 | 11 | TABLE OF CONTENTS 12 | 13 | 1. WHAT INFORMATION DO WE COLLECT? 14 | 2. HOW DO WE USE YOUR INFORMATION? 15 | 3. WILL YOUR INFORMATION BE SHARED WITH ANYONE? 16 | 4. DO WE USE COOKIES AND OTHER TRACKING TECHNOLOGIES? 17 | 5. HOW LONG DO WE KEEP YOUR INFORMATION? 18 | 6. HOW DO WE KEEP YOUR INFORMATION SAFE? 19 | 7. DO WE COLLECT INFORMATION FROM MINORS? 20 | 8. WHAT ARE YOUR PRIVACY RIGHTS? 21 | 9. CONTROLS FOR DO-NOT-TRACK FEATURES 22 | 10. DO CALIFORNIA RESIDENTS HAVE SPECIFIC PRIVACY RIGHTS? 23 | 11. DO WE MAKE UPDATES TO THIS POLICY? 24 | 12. HOW CAN YOU CONTACT US ABOUT THIS POLICY? 25 | 26 | 1. WHAT INFORMATION DO WE COLLECT? 27 | 28 | Information collected through our Apps 29 | 30 | In Short: We may collect information regarding your mobile device, when you use our apps. 31 | 32 | If you use our Apps, we may also collect the following information: 33 | 34 | Mobile Device Access. We may request access or permission to certain features from your mobile device, including your mobile device's camera, and other features. If you wish to change our access or permissions, you may do so in your device's settings. 35 | 36 | 2. HOW DO WE USE YOUR INFORMATION? 37 | 38 | In Short: We process your information for purposes based on legitimate business interests, the fulfillment of our contract with you, compliance with our legal obligations, and/or your consent. 39 | 40 | We use personal information collected via our Apps for a variety of business purposes described below. We process your personal information for these purposes in reliance on our legitimate business interests, in order to enter into or perform a contract with you, with your consent, and/or for compliance with our legal obligations. We indicate the specific processing grounds we rely on next to each purpose listed below. 41 | 42 | We use the information we collect or receive: 43 | 44 | For other Business Purposes. We may use your information for other Business Purposes, such as data analysis, identifying usage trends, determining the effectiveness of our promotional campaigns and to evaluate and improve our Apps, products, marketing and your experience. We may use and store this information in aggregated and anonymized form so that it is not associated with individual end users and does not include personal information. We will not use identifiable personal information without your consent. 45 | 46 | 3. WILL YOUR INFORMATION BE SHARED WITH ANYONE? 47 | 48 | In Short: We only share information with your consent, to comply with laws, to provide you with services, to protect your rights, or to fulfill business obligations. 49 | 50 | We may process or share data based on the following legal basis: 51 | 52 | Consent: We may process your data if you have given us specific consent to use your personal information in a specific purpose. 53 | 54 | Legitimate Interests: We may process your data when it is reasonably necessary to achieve our legitimate business interests. 55 | 56 | Performance of a Contract: Where we have entered into a contract with you, we may process your personal information to fulfill the terms of our contract. 57 | 58 | Legal Obligations: We may disclose your information where we are legally required to do so in order to comply with applicable law, governmental requests, a judicial proceeding, court order, or legal process, such as in response to a court order or a subpoena (including in response to public authorities to meet national security or law enforcement requirements). 59 | 60 | Vital Interests: We may disclose your information where we believe it is necessary to investigate, prevent, or take action regarding potential violations of our policies, suspected fraud, situations involving potential threats to the safety of any person and illegal activities, or as evidence in litigation in which we are involved. 61 | 62 | More specifically, we may need to process your data or share your personal information in the following situations: 63 | 64 | Vendors, Consultants and Other Third-Party Service Providers. We may share your data with third party vendors, service providers, contractors or agents who perform services for us or on our behalf and require access to such information to do that work. Examples include: payment processing, data analysis, email delivery, hosting services, customer service and marketing efforts. We may allow selected third parties to use tracking technology on the Apps, which will enable them to collect data about how you interact with the Apps over time. This information may be used to, among other things, analyze and track data, determine the popularity of certain content and better understand online activity. Unless described in this Policy, we do not share, sell, rent or trade any of your information with third parties for their promotional purposes. 65 | 66 | Business Transfers. We may share or transfer your information in connection with, or during negotiations of, any merger, sale of company assets, financing, or acquisition of all or a portion of our business to another company. 67 | 68 | Third-Party Advertisers. We may use third-party advertising companies to serve ads when you visit the Apps. These companies may use information about your visits to our Website(s) and other websites that are contained in web cookies and other tracking technologies in order to provide advertisements about goods and services of interest to you. 69 | 70 | 4. DO WE USE COOKIES AND OTHER TRACKING TECHNOLOGIES? 71 | 72 | In Short: We may use cookies and other tracking technologies to collect and store your information. 73 | 74 | We may use cookies and similar tracking technologies (like web beacons and pixels) to access or store information. Specific information about how we use such technologies and how you can refuse certain cookies is set out in our Cookie Policy. 75 | 76 | 5. HOW LONG DO WE KEEP YOUR INFORMATION? 77 | 78 | In Short: We keep your information for as long as necessary to fulfill the purposes outlined in this privacy notice unless otherwise required by law. 79 | 80 | We will only keep your personal information for as long as it is necessary for the purposes set out in this privacy notice, unless a longer retention period is required or permitted by law (such as tax, accounting or other legal requirements). No purpose in this policy will require us keeping your personal information for longer than the period of time in which users have an account with us. 81 | 82 | When we have no ongoing legitimate business need to process your personal information, we will either delete or anonymize it, or, if this is not possible (for example, because your personal information has been stored in backup archives), then we will securely store your personal information and isolate it from any further processing until deletion is possible. 83 | 84 | 6. HOW DO WE KEEP YOUR INFORMATION SAFE? 85 | 86 | In Short: We aim to protect your personal information through a system of organizational and technical security measures. 87 | 88 | We have implemented appropriate technical and organizational security measures designed to protect the security of any personal information we process. However, please also remember that we cannot guarantee that the internet itself is 100% secure. Although we will do our best to protect your personal information, transmission of personal information to and from our Apps is at your own risk. You should only access the services within a secure environment. 89 | 90 | 7. DO WE COLLECT INFORMATION FROM MINORS? 91 | 92 | In Short: We do not knowingly collect data from or market to children under 18 years of age. 93 | 94 | We do not knowingly solicit data from or market to children under 18 years of age. By using the Apps, you represent that you are at least 18 or that you are the parent or guardian of such a minor and consent to such minor dependent’s use of the Apps. If we learn that personal information from users less than 18 years of age has been collected, we will deactivate the account and take reasonable measures to promptly delete such data from our records. If you become aware of any data we have collected from children under age 18, please contact us at kenmueller0@gmail.com. 95 | 96 | 8. WHAT ARE YOUR PRIVACY RIGHTS? 97 | 98 | In Short: In some regions, such as the European Economic Area, you have rights that allow you greater access to and control over your personal information. You may review, change, or terminate your account at any time. 99 | 100 | In some regions (like the European Economic Area), you have certain rights under applicable data protection laws. These may include the right (i) to request access and obtain a copy of your personal information, (ii) to request rectification or erasure; (iii) to restrict the processing of your personal information; and (iv) if applicable, to data portability. In certain circumstances, you may also have the right to object to the processing of your personal information. To make such a request, please use the contact details provided below. We will consider and act upon any request in accordance with applicable data protection laws. 101 | 102 | If we are relying on your consent to process your personal information, you have the right to withdraw your consent at any time. Please note however that this will not affect the lawfulness of the processing before its withdrawal. 103 | 104 | If you are resident in the European Economic Area and you believe we are unlawfully processing your personal information, you also have the right to complain to your local data protection supervisory authority. You can find their contact details here: http://ec.europa.eu/justice/data-protection/bodies/authorities/index_en.htm. 105 | 106 | Account Information 107 | 108 | If you would at any time like to review or change the information in your account or terminate your account, you can: 109 | 110 | Log into your account settings and update your user account. 111 | 112 | Upon your request to terminate your account, we will deactivate or delete your account and information from our active databases. However, some information may be retained in our files to prevent fraud, troubleshoot problems, assist with any investigations, enforce our Terms of Use and/or comply with legal requirements. 113 | 114 | Cookies and similar technologies: Most Web browsers are set to accept cookies by default. If you prefer, you can usually choose to set your browser to remove cookies and to reject cookies. If you choose to remove cookies or reject cookies, this could affect certain features or services of our Apps. To opt-out of interest-based advertising by advertisers on our Apps visit http://www.aboutads.info/choices/. 115 | 116 | Opting out of email marketing: You can unsubscribe from our marketing email list at any time by clicking on the unsubscribe link in the emails that we send or by contacting us using the details provided below. You will then be removed from the marketing email list – however, we will still need to send you service-related emails that are necessary for the administration and use of your account. To otherwise opt-out, you may: 117 | 118 | 9. CONTROLS FOR DO-NOT-TRACK FEATURES 119 | 120 | Most web browsers and some mobile operating systems and mobile applications include a Do-Not-Track ("DNT") feature or setting you can activate to signal your privacy preference not to have data about your online browsing activities monitored and collected. No uniform technology standard for recognizing and implementing DNT signals has been finalized. As such, we do not currently respond to DNT browser signals or any other mechanism that automatically communicates your choice not to be tracked online. If a standard for online tracking is adopted that we must follow in the future, we will inform you about that practice in a revised version of this privacy notice. 121 | 122 | 10. DO CALIFORNIA RESIDENTS HAVE SPECIFIC PRIVACY RIGHTS? 123 | 124 | In Short: Yes, if you are a resident of California, you are granted specific rights regarding access to your personal information. 125 | 126 | California Civil Code Section 1798.83, also known as the "Shine The Light" law, permits our users who are California residents to request and obtain from us, once a year and free of charge, information about categories of personal information (if any) we disclosed to third parties for direct marketing purposes and the names and addresses of all third parties with which we shared personal information in the immediately preceding calendar year. If you are a California resident and would like to make such a request, please submit your request in writing to us using the contact information provided below. 127 | 128 | If you are under 18 years of age, reside in California, and have a registered account with the Apps, you have the right to request removal of unwanted data that you publicly post on the Apps. To request removal of such data, please contact us using the contact information provided below, and include the email address associated with your account and a statement that you reside in California. We will make sure the data is not publicly displayed on the Apps, but please be aware that the data may not be completely or comprehensively removed from our systems. 129 | 130 | 11. DO WE MAKE UPDATES TO THIS POLICY? 131 | 132 | In Short: Yes, we will update this policy as necessary to stay compliant with relevant laws. 133 | 134 | We may update this privacy notice from time to time. The updated version will be indicated by an updated "Revised" date and the updated version will be effective as soon as it is accessible. If we make material changes to this privacy notice, we may notify you either by prominently posting a notice of such changes or by directly sending you a notification. We encourage you to review this privacy notice frequently to be informed of how we are protecting your information. 135 | 136 | 12. HOW CAN YOU CONTACT US ABOUT THIS POLICY? 137 | 138 | If you have questions or comments about this policy, you may email us at kenmueller0@gmail.com. 139 | 140 | HOW CAN YOU REVIEW, UPDATE, OR DELETE THE DATA WE COLLECT FROM YOU? 141 | 142 | Based on the laws of some countries, you may have the right to request access to the personal information we collect from you, change that information, or delete it in some circumstances. To request to review, update, or delete your personal information, please submit a request form by clicking here. We will respond to your request within 30 days. 143 | -------------------------------------------------------------------------------- /hooks/useComments.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useRecoilState } from 'recoil' 3 | import { toast } from 'react-toastify' 4 | 5 | import FileMeta from 'models/FileMeta' 6 | import firebase from 'lib/firebase' 7 | import snapshotToComment from 'lib/snapshotToComment' 8 | import commentsState from 'state/comments' 9 | 10 | import 'firebase/firestore' 11 | 12 | const firestore = firebase.firestore() 13 | const queue = new Set() 14 | 15 | const useComments = (file: FileMeta) => { 16 | const [comments, setComments] = useRecoilState(commentsState) 17 | 18 | useEffect(() => { 19 | if (queue.has(file.id)) 20 | return 21 | 22 | queue.add(file.id) 23 | 24 | firestore.collection(`files/${file.id}/comments`).onSnapshot( 25 | snapshot => { 26 | setComments(_comments => { 27 | let comments = _comments[file.id] ?? [] 28 | 29 | for (const { type, doc } of snapshot.docChanges()) 30 | switch (type) { 31 | case 'added': { 32 | const comment = snapshotToComment(doc) 33 | 34 | if (comment) 35 | comments = [...comments, comment] 36 | 37 | break 38 | } 39 | case 'modified': 40 | comments = comments.map(comment => 41 | comment.id === doc.id 42 | ? snapshotToComment(doc) ?? comment 43 | : comment 44 | ) 45 | break 46 | case 'removed': 47 | comments = comments.filter(({ id }) => id !== doc.id) 48 | break 49 | } 50 | 51 | return { 52 | ..._comments, 53 | [file.id]: comments.sort((a, b) => a.date - b.date) 54 | } 55 | }) 56 | }, 57 | ({ message }) => toast.error(message) 58 | ) 59 | }, [file.id, setComments]) 60 | 61 | return comments[file.id] 62 | } 63 | 64 | export default useComments 65 | -------------------------------------------------------------------------------- /hooks/useCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useRecoilState } from 'recoil' 3 | import { toast } from 'react-toastify' 4 | 5 | import firebase from 'lib/firebase' 6 | import currentUserState from 'state/currentUser' 7 | import useUser, { UseUserOptions } from './useUser' 8 | 9 | import 'firebase/auth' 10 | 11 | const USE_USER_OPTIONS: UseUserOptions = { 12 | observe: true, 13 | includeApiKey: true 14 | } 15 | 16 | const auth = firebase.auth() 17 | 18 | const useCurrentUser = () => { 19 | const [currentUser, setCurrentUser] = useRecoilState(currentUserState) 20 | const user = useUser(currentUser?.auth?.uid, USE_USER_OPTIONS) 21 | 22 | useEffect(() => { 23 | if (currentUser !== undefined) 24 | return 25 | 26 | auth.onAuthStateChanged( 27 | auth => setCurrentUser(auth && { auth, data: null }), 28 | ({ message }) => toast.error(message) 29 | ) 30 | }, [currentUser, setCurrentUser]) 31 | 32 | useEffect(() => { 33 | setCurrentUser(currentUser => currentUser && { 34 | ...currentUser, 35 | data: user ?? null 36 | }) 37 | }, [user, setCurrentUser]) 38 | 39 | return currentUser 40 | } 41 | 42 | export default useCurrentUser 43 | -------------------------------------------------------------------------------- /hooks/useFiles.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useRecoilState } from 'recoil' 3 | import { toast } from 'react-toastify' 4 | 5 | import firebase from 'lib/firebase' 6 | import snapshotToFileMeta from 'lib/snapshotToFileMeta' 7 | import filesState from 'state/files' 8 | import useCurrentUser from './useCurrentUser' 9 | 10 | import 'firebase/firestore' 11 | 12 | const firestore = firebase.firestore() 13 | const queue = new Set() 14 | 15 | const useFiles = (owner: string) => { 16 | const [files, setFiles] = useRecoilState(filesState) 17 | 18 | const currentUser = useCurrentUser() 19 | const uid = currentUser && (currentUser.auth?.uid ?? currentUser.data?.id) 20 | 21 | useEffect(() => { 22 | if (uid === undefined || queue.has(owner)) 23 | return 24 | 25 | queue.add(owner) 26 | 27 | let query = firestore.collection('files').where('owner', '==', owner) 28 | 29 | if (uid !== owner) 30 | query = query.where('public', '==', true) 31 | 32 | query.onSnapshot( 33 | snapshot => { 34 | setFiles(_files => { 35 | let files = _files[owner] ?? [] 36 | 37 | for (const { type, doc } of snapshot.docChanges()) 38 | switch (type) { 39 | case 'added': { 40 | const file = snapshotToFileMeta(doc) 41 | 42 | if (file) 43 | files = [...files, file] 44 | 45 | break 46 | } 47 | case 'modified': 48 | files = files.map(file => 49 | file.id === doc.id 50 | ? snapshotToFileMeta(doc) ?? file 51 | : file 52 | ) 53 | break 54 | case 'removed': 55 | files = files.filter(({ id }) => id !== doc.id) 56 | break 57 | } 58 | 59 | return { 60 | ..._files, 61 | [owner]: files.sort((a, b) => b.uploaded - a.uploaded) 62 | } 63 | }) 64 | }, 65 | ({ message }) => toast.error(message) 66 | ) 67 | }, [uid, owner, setFiles]) 68 | 69 | return files[owner] 70 | } 71 | 72 | export default useFiles 73 | -------------------------------------------------------------------------------- /hooks/useHideOverlays.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { useSetRecoilState } from 'recoil' 3 | 4 | import currentFileState from 'state/currentFile' 5 | import uploadFileState from 'state/uploadFile' 6 | 7 | const useHideOverlays = () => { 8 | const setCurrentFile = useSetRecoilState(currentFileState) 9 | const setUploadFile = useSetRecoilState(uploadFileState) 10 | 11 | return useCallback(() => { 12 | setCurrentFile(null) 13 | setUploadFile(null) 14 | }, [setCurrentFile, setUploadFile]) 15 | } 16 | 17 | export default useHideOverlays 18 | -------------------------------------------------------------------------------- /hooks/useRecentlyUploadedFiles.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useRecoilState } from 'recoil' 3 | import { toast } from 'react-toastify' 4 | 5 | import firebase from 'lib/firebase' 6 | import snapshotToFileMeta from 'lib/snapshotToFileMeta' 7 | import { RECENTLY_UPLOADED_FILES_LIMIT } from 'lib/constants' 8 | import recentlyUploadedFilesState from 'state/recentlyUploadedFiles' 9 | import useCurrentUser from './useCurrentUser' 10 | 11 | import 'firebase/firestore' 12 | 13 | const firestore = firebase.firestore() 14 | 15 | let isListeningToAll = false 16 | let isListeningToUser = false 17 | 18 | const useRecentlyUploadedFiles = () => { 19 | const [files, setFiles] = useRecoilState(recentlyUploadedFilesState) 20 | 21 | const currentUser = useCurrentUser() 22 | const uid = currentUser && (currentUser.auth?.uid ?? currentUser.data?.id) 23 | 24 | useEffect(() => { 25 | if (isListeningToAll) 26 | return 27 | 28 | isListeningToAll = true 29 | 30 | firestore 31 | .collection('files') 32 | .where('public', '==', true) 33 | .orderBy('uploaded', 'desc') 34 | .limit(RECENTLY_UPLOADED_FILES_LIMIT) 35 | .onSnapshot( 36 | snapshot => { 37 | setFiles(_files => { 38 | let files = _files ?? [] 39 | 40 | for (const { type, doc } of snapshot.docChanges()) 41 | switch (type) { 42 | case 'added': { 43 | const file = snapshotToFileMeta(doc) 44 | 45 | if (file) 46 | files = [...files, file] 47 | 48 | break 49 | } 50 | case 'modified': { 51 | const newFile = snapshotToFileMeta(doc) 52 | 53 | if (newFile) 54 | files = files.map(file => 55 | file.id === newFile.id ? newFile : file 56 | ) 57 | 58 | break 59 | } 60 | case 'removed': 61 | for (const file of files) 62 | if (file.id === doc.id) { 63 | files = files.filter(_file => _file !== file) 64 | break 65 | } 66 | 67 | break 68 | } 69 | 70 | return files.sort((a, b) => b.uploaded - a.uploaded) 71 | }) 72 | }, 73 | ({ message }) => toast.error(message) 74 | ) 75 | }, [setFiles]) 76 | 77 | useEffect(() => { 78 | if (isListeningToUser || !uid) 79 | return 80 | 81 | isListeningToUser = true 82 | 83 | firestore 84 | .collection('files') 85 | .where('owner', '==', uid) 86 | .where('public', '==', false) 87 | .onSnapshot( 88 | snapshot => { 89 | setFiles(_files => { 90 | let files = _files ?? [] 91 | 92 | for (const { type, doc } of snapshot.docChanges()) 93 | switch (type) { 94 | case 'added': { 95 | const file = snapshotToFileMeta(doc) 96 | 97 | if (file) 98 | files = [...files, file] 99 | 100 | break 101 | } 102 | case 'modified': { 103 | const newFile = snapshotToFileMeta(doc) 104 | 105 | if (newFile) 106 | files = files.map(file => 107 | file.id === newFile.id ? newFile : file 108 | ) 109 | 110 | break 111 | } 112 | case 'removed': 113 | for (const file of files) 114 | if (file.id === doc.id) { 115 | files = files.filter(_file => _file !== file) 116 | break 117 | } 118 | 119 | break 120 | } 121 | 122 | return files.sort((a, b) => b.uploaded - a.uploaded) 123 | }) 124 | }, 125 | ({ message }) => toast.error(message) 126 | ) 127 | }, [uid, setFiles]) 128 | 129 | return files 130 | } 131 | 132 | export default useRecentlyUploadedFiles 133 | -------------------------------------------------------------------------------- /hooks/useSignIn.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { useSetRecoilState } from 'recoil' 3 | 4 | import signIn from 'lib/signIn' 5 | import currentUserState from 'state/currentUser' 6 | 7 | const useSignIn = () => { 8 | const setCurrentUser = useSetRecoilState(currentUserState) 9 | 10 | return useCallback(async () => { 11 | const data = await signIn() 12 | 13 | if (data) 14 | setCurrentUser(user => ({ 15 | auth: user?.auth ?? null, 16 | data 17 | })) 18 | 19 | return data !== undefined 20 | }, [setCurrentUser]) 21 | } 22 | 23 | export default useSignIn 24 | -------------------------------------------------------------------------------- /hooks/useUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react' 2 | 3 | const useUpdate = () => { 4 | const [, setState] = useState({}) 5 | return useCallback(() => setState({}), [setState]) 6 | } 7 | 8 | export default useUpdate 9 | -------------------------------------------------------------------------------- /hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react' 2 | import { useRecoilState } from 'recoil' 3 | import { toast } from 'react-toastify' 4 | 5 | import firebase from 'lib/firebase' 6 | import snapshotToUser from 'lib/snapshotToUser' 7 | import usersState from 'state/users' 8 | 9 | import 'firebase/firestore' 10 | 11 | const firestore = firebase.firestore() 12 | 13 | const getQueue = new Set() 14 | const observeQueue = new Set() 15 | 16 | const onError = ({ message }: Error) => { 17 | toast.error(message) 18 | } 19 | 20 | export interface UseUserOptions { 21 | observe?: boolean 22 | includeApiKey?: boolean 23 | } 24 | 25 | /** 26 | * - `undefined` - loading 27 | * - `null` - nonexistent 28 | * - `User` - loaded 29 | */ 30 | const useUser = ( 31 | id: string | null | undefined, 32 | { observe = false, includeApiKey = false }: UseUserOptions = {} 33 | ) => { 34 | const [users, setUsers] = useRecoilState(usersState) 35 | 36 | const onSnapshot = useCallback((snapshot: firebase.firestore.DocumentSnapshot) => { 37 | setUsers(users => ({ 38 | ...users, 39 | [id]: snapshotToUser(snapshot, includeApiKey) 40 | })) 41 | }, [id, includeApiKey, setUsers]) 42 | 43 | useEffect(() => { 44 | if (!id) 45 | return 46 | 47 | const queue = observe ? observeQueue : getQueue 48 | 49 | if (queue.has(id)) 50 | return 51 | 52 | queue.add(id) 53 | const doc = firestore.doc(`users/${id}`) 54 | 55 | observe 56 | ? doc.onSnapshot(onSnapshot, onError) 57 | : doc.get().then(onSnapshot).catch(onError) 58 | }, [id, observe, onSnapshot]) 59 | 60 | return id && users[id] 61 | } 62 | 63 | export default useUser 64 | -------------------------------------------------------------------------------- /images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenmueller/filein-frontend/f8cf657ddb9ca40b6e287e166296b61ac8bc8f62/images/apple-touch-icon.png -------------------------------------------------------------------------------- /images/icon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenmueller/filein-frontend/f8cf657ddb9ca40b6e287e166296b61ac8bc8f62/images/icon-16x16.png -------------------------------------------------------------------------------- /images/icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenmueller/filein-frontend/f8cf657ddb9ca40b6e287e166296b61ac8bc8f62/images/icon-32x32.png -------------------------------------------------------------------------------- /images/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /images/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 30 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /lib/access.ts: -------------------------------------------------------------------------------- 1 | const KEY = 'filein.io-file-private' 2 | 3 | export const getIsPublic = () => 4 | !localStorage.getItem(KEY) 5 | 6 | export const setIsPublic = (isPublic: boolean) => 7 | isPublic 8 | ? localStorage.removeItem(KEY) 9 | : localStorage.setItem(KEY, '1') 10 | -------------------------------------------------------------------------------- /lib/clipBody.ts: -------------------------------------------------------------------------------- 1 | const clipBody = (clipped: boolean) => 2 | document.body.classList[clipped ? 'add' : 'remove']('clipped') 3 | 4 | export default clipBody 5 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const RECENTLY_UPLOADED_FILES_LIMIT = 100 2 | export const REVALIDATE = 1 3 | 4 | export const MAX_FILE_SIZE = 10 * 1024 * 1024 * 1024 // 10 GB 5 | -------------------------------------------------------------------------------- /lib/deleteFile.ts: -------------------------------------------------------------------------------- 1 | import FileMeta from 'models/FileMeta' 2 | import firebase from './firebase' 3 | 4 | import 'firebase/firestore' 5 | 6 | const firestore = firebase.firestore() 7 | 8 | const deleteFile = (file: FileMeta) => 9 | firestore.doc(`files/${file.id}`).delete() 10 | 11 | export default deleteFile 12 | -------------------------------------------------------------------------------- /lib/editAccess.ts: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify' 2 | 3 | import FileMeta from 'models/FileMeta' 4 | import firebase from './firebase' 5 | import { setIsPublic } from './access' 6 | 7 | import 'firebase/firestore' 8 | 9 | const firestore = firebase.firestore() 10 | 11 | const editAccess = async (file: FileMeta, isPublic: boolean) => { 12 | const _isPublic = file.public 13 | 14 | try { 15 | file.public = isPublic 16 | setIsPublic(isPublic) 17 | 18 | await firestore.doc(`files/${file.id}`).update({ 19 | public: isPublic 20 | }) 21 | } catch ({ message }) { 22 | file.public = _isPublic 23 | setIsPublic(_isPublic) 24 | 25 | toast.error(message) 26 | } 27 | } 28 | 29 | export default editAccess 30 | -------------------------------------------------------------------------------- /lib/editFileName.ts: -------------------------------------------------------------------------------- 1 | import FileMeta from 'models/FileMeta' 2 | import firebase from './firebase' 3 | 4 | import 'firebase/firestore' 5 | 6 | const firestore = firebase.firestore() 7 | 8 | const editFileName = ({ id }: FileMeta, name: string) => 9 | firestore.doc(`files/${id}`).update({ name }) 10 | 11 | export default editFileName 12 | -------------------------------------------------------------------------------- /lib/editUserName.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | 3 | import 'firebase/firestore' 4 | 5 | const firestore = firebase.firestore() 6 | 7 | const editUserName = (uid: string, name: string) => 8 | firestore.doc(`users/${uid}`).update({ name }) 9 | 10 | export default editUserName 11 | -------------------------------------------------------------------------------- /lib/firebase/admin.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | if (!firebase.apps.length) 4 | firebase.initializeApp({ 5 | credential: firebase.credential.cert( 6 | JSON.parse(Buffer.from(process.env.FIREBASE_ADMIN_KEY, 'base64').toString()) 7 | ), 8 | databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL, 9 | storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET 10 | }) 11 | 12 | export default firebase 13 | -------------------------------------------------------------------------------- /lib/firebase/index.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app' 2 | 3 | if (!firebase.apps.length) 4 | firebase.initializeApp({ 5 | apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, 6 | authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, 7 | databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL, 8 | projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, 9 | storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, 10 | messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, 11 | appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, 12 | measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID 13 | }) 14 | 15 | export default firebase 16 | -------------------------------------------------------------------------------- /lib/getFile.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | import snapshotToFileMeta from './snapshotToFileMeta' 3 | 4 | import 'firebase/firestore' 5 | 6 | const firestore = firebase.firestore() 7 | 8 | const getFile = async (id: string) => 9 | snapshotToFileMeta(await firestore.doc(`files/${id}`).get()) 10 | 11 | export default getFile 12 | -------------------------------------------------------------------------------- /lib/getFileIcon.ts: -------------------------------------------------------------------------------- 1 | import { faCode, faFile, faFileAlt, faFileArchive, faFileCsv, faFileExcel, faFilePdf, faFilePowerpoint, faFileWord, faVideo, faVolumeUp } from '@fortawesome/free-solid-svg-icons' 2 | 3 | import FileMeta from 'models/FileMeta' 4 | 5 | const getFileIcon = ({ type }: FileMeta) => { 6 | switch (type) { 7 | case 'application/pdf': 8 | return faFilePdf 9 | case 'application/rtf': 10 | return faFileAlt 11 | case 'text/html': 12 | case 'text/css': 13 | case 'text/javascript': 14 | case 'application/json': 15 | case 'application/ld+json': 16 | case 'application/java-archive': 17 | case 'application/xhtml+xml': 18 | case 'application/xml': 19 | case 'text/xml': 20 | return faCode 21 | case 'text/csv': 22 | return faFileCsv 23 | case 'application/msword': 24 | case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 25 | return faFileWord 26 | case 'application/vnd.ms-powerpoint': 27 | case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 28 | return faFilePowerpoint 29 | case 'application/vnd.ms-excel': 30 | case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 31 | return faFileExcel 32 | case 'application/zip': 33 | case 'application/gzip': 34 | case 'application/vnd.rar': 35 | case 'application/x-tar': 36 | return faFileArchive 37 | } 38 | 39 | if (type.startsWith('text/') || type.startsWith('font/')) return faFileAlt 40 | if (type.startsWith('audio/')) return faVolumeUp 41 | if (type.startsWith('video/')) return faVideo 42 | 43 | return faFile 44 | } 45 | 46 | export default getFileIcon 47 | -------------------------------------------------------------------------------- /lib/getFileIds.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | 3 | import 'firebase/firestore' 4 | 5 | const LIMIT = 1000 6 | 7 | const firestore = firebase.firestore() 8 | 9 | const getFileIds = async () => { 10 | const { docs } = await firestore 11 | .collection('files') 12 | .orderBy('uploaded', 'desc') 13 | .limit(LIMIT) 14 | .get() 15 | 16 | return docs.map(({ id }) => id) 17 | } 18 | 19 | export default getFileIds 20 | -------------------------------------------------------------------------------- /lib/getFilePredicate.ts: -------------------------------------------------------------------------------- 1 | import FileMeta from 'models/FileMeta' 2 | import normalize from 'lib/normalize' 3 | 4 | const getFilePredicate = (query: string) => { 5 | const characters = normalize(query).split('') 6 | 7 | return (file: FileMeta) => { 8 | const name = file.name.toLowerCase() 9 | 10 | for (const character of characters) 11 | if (!name.includes(character)) 12 | return false 13 | 14 | return true 15 | } 16 | } 17 | 18 | export default getFilePredicate 19 | -------------------------------------------------------------------------------- /lib/getFileType.ts: -------------------------------------------------------------------------------- 1 | import FileMeta from 'models/FileMeta' 2 | import FileType from 'models/FileType' 3 | 4 | const getFileType = ({ type }: FileMeta) => { 5 | if (type.startsWith('image/')) return FileType.Image 6 | if (type.startsWith('video/')) return FileType.Video 7 | if (type.startsWith('audio/')) return FileType.Audio 8 | if (type === 'application/pdf') return FileType.PDF 9 | 10 | return FileType.Other 11 | } 12 | 13 | export default getFileType 14 | -------------------------------------------------------------------------------- /lib/getFileUrl.ts: -------------------------------------------------------------------------------- 1 | import FileMeta from 'models/FileMeta' 2 | 3 | const getFileUrl = (file: FileMeta, secure: boolean = false) => 4 | `${secure ? 'https://storage.googleapis.com/' : 'http://'}u.filein.io/${file.id}` 5 | 6 | export default getFileUrl 7 | -------------------------------------------------------------------------------- /lib/getFiles.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | import snapshotToFileMeta from './snapshotToFileMeta' 3 | 4 | import 'firebase/firestore' 5 | 6 | const firestore = firebase.firestore() 7 | 8 | const getFiles = async (uid: string) => { 9 | const { docs } = await firestore 10 | .collection('files') 11 | .where('owner', '==', uid) 12 | .where('public', '==', true) 13 | .get() 14 | 15 | return docs 16 | .map(snapshotToFileMeta) 17 | .sort((a, b) => b.uploaded - a.uploaded) 18 | } 19 | 20 | export default getFiles 21 | -------------------------------------------------------------------------------- /lib/getRecentlyUploadedFiles.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | import snapshotToFileMeta from './snapshotToFileMeta' 3 | import { RECENTLY_UPLOADED_FILES_LIMIT } from './constants' 4 | 5 | import 'firebase/firestore' 6 | 7 | const firestore = firebase.firestore() 8 | 9 | const getRecentlyUploadedFiles = async () => { 10 | const { docs } = await firestore 11 | .collection('files') 12 | .where('public', '==', true) 13 | .orderBy('uploaded', 'desc') 14 | .limit(RECENTLY_UPLOADED_FILES_LIMIT) 15 | .get() 16 | 17 | return docs.map(snapshotToFileMeta) 18 | } 19 | 20 | export default getRecentlyUploadedFiles 21 | -------------------------------------------------------------------------------- /lib/getUser.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | import snapshotToUser from './snapshotToUser' 3 | 4 | import 'firebase/firestore' 5 | 6 | const firestore = firebase.firestore() 7 | 8 | const getUser = async (id: string) => 9 | snapshotToUser(await firestore.doc(`users/${id}`).get()) 10 | 11 | export default getUser 12 | -------------------------------------------------------------------------------- /lib/getUserFromSlug.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | import snapshotToUser from './snapshotToUser' 3 | 4 | import 'firebase/firestore' 5 | 6 | const firestore = firebase.firestore() 7 | 8 | const getUserFromSlug = async (slug: string) => { 9 | const { empty, docs } = await firestore 10 | .collection('users') 11 | .where('slug', '==', slug) 12 | .limit(1) 13 | .get() 14 | 15 | return empty ? null : snapshotToUser(docs[0]) 16 | } 17 | 18 | export default getUserFromSlug 19 | -------------------------------------------------------------------------------- /lib/getUserSlug.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | 3 | import firebase from './firebase' 4 | import toSlug from './toSlug' 5 | 6 | import 'firebase/firestore' 7 | 8 | const firestore = firebase.firestore() 9 | 10 | const getUserSlug = async (name: string) => { 11 | const slug = toSlug(name) 12 | const { empty } = await firestore 13 | .collection('users') 14 | .where('slug', '==', slug) 15 | .limit(1) 16 | .get() 17 | 18 | return empty 19 | ? slug 20 | : `${slug}-${nanoid(5)}` 21 | } 22 | 23 | export default getUserSlug 24 | -------------------------------------------------------------------------------- /lib/getUserSlugs.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | 3 | import 'firebase/firestore' 4 | 5 | const firestore = firebase.firestore() 6 | 7 | const getUserSlugs = async () => { 8 | const { docs } = await firestore.collection('users').get() 9 | return docs.map(snapshot => snapshot.get('slug') as string) 10 | } 11 | 12 | export default getUserSlugs 13 | -------------------------------------------------------------------------------- /lib/newId.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | 3 | const ID_LENGTH = 10 4 | 5 | const newId = () => 6 | nanoid(ID_LENGTH) 7 | 8 | export default newId 9 | -------------------------------------------------------------------------------- /lib/normalize.ts: -------------------------------------------------------------------------------- 1 | const normalize = (string: string) => 2 | string.toLowerCase().replace(/\s/g, '') 3 | 4 | export default normalize 5 | -------------------------------------------------------------------------------- /lib/normalizeExtension.ts: -------------------------------------------------------------------------------- 1 | const NAME_REGEX = /^(.+)\./ 2 | const EXTENSION_REGEX = /\.([^\.]+)$/ 3 | 4 | const getName = (file: string) => 5 | file.match(NAME_REGEX)?.[1] ?? file 6 | 7 | const getExtension = (file: string) => 8 | file.match(EXTENSION_REGEX)?.[1].toLowerCase() 9 | 10 | const normalizeExtension = (from: string, to: string) => { 11 | const fromExtension = getExtension(from) 12 | const toName = getName(to) 13 | const toExtension = getExtension(to) 14 | 15 | return `${toName}${ 16 | fromExtension 17 | ? `.${fromExtension}` 18 | : toExtension ? `.${toExtension}` : '' 19 | }` 20 | } 21 | 22 | export default normalizeExtension 23 | -------------------------------------------------------------------------------- /lib/signIn.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | import getSlug from './getUserSlug' 3 | 4 | import 'firebase/auth' 5 | import 'firebase/firestore' 6 | import User from 'models/User' 7 | 8 | const auth = firebase.auth() 9 | const firestore = firebase.firestore() 10 | 11 | const provider = new firebase.auth.GoogleAuthProvider() 12 | provider.addScope('https://www.googleapis.com/auth/userinfo.email') 13 | 14 | const signIn = async (): Promise => { 15 | try { 16 | const { 17 | user, 18 | additionalUserInfo 19 | } = await auth.signInWithPopup(provider) 20 | 21 | if (!(user && additionalUserInfo)) 22 | throw new Error('An unknown error occurred. Please try again') 23 | 24 | if (!user.email) 25 | throw new Error('Unable to get your email address') 26 | 27 | if (additionalUserInfo.isNewUser) { 28 | const name = user.displayName ?? 'Anonymous' 29 | const slug = await getSlug(name) 30 | 31 | await firestore.doc(`users/${user.uid}`).set({ 32 | slug, 33 | apiKey: null, 34 | name, 35 | email: user.email, 36 | files: 0, 37 | comments: 0, 38 | joined: firebase.firestore.FieldValue.serverTimestamp() 39 | }) 40 | 41 | return { id: user.uid, slug, name, files: 0, comments: 0 } 42 | } 43 | 44 | return null 45 | } catch (error) { 46 | switch (error.code) { 47 | case 'auth/popup-closed-by-user': 48 | return 49 | default: 50 | throw error 51 | } 52 | } 53 | } 54 | 55 | export default signIn 56 | -------------------------------------------------------------------------------- /lib/signOut.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | 3 | import 'firebase/auth' 4 | 5 | const auth = firebase.auth() 6 | 7 | const signOut = () => 8 | auth.signOut() 9 | 10 | export default signOut 11 | -------------------------------------------------------------------------------- /lib/snapshotToComment.ts: -------------------------------------------------------------------------------- 1 | import Comment from 'models/Comment' 2 | import firebase from './firebase' 3 | 4 | const snapshotToComment = (snapshot: firebase.firestore.DocumentSnapshot | FirebaseFirestore.DocumentSnapshot): Comment | null => 5 | snapshot.exists 6 | ? { 7 | id: snapshot.id, 8 | from: snapshot.get('from'), 9 | body: snapshot.get('body'), 10 | date: snapshot.get('date')?.toMillis() 11 | } 12 | : null 13 | 14 | export default snapshotToComment 15 | -------------------------------------------------------------------------------- /lib/snapshotToFileMeta.ts: -------------------------------------------------------------------------------- 1 | import FileMeta from 'models/FileMeta' 2 | import firebase from './firebase' 3 | 4 | const snapshotToFileMeta = (snapshot: firebase.firestore.DocumentSnapshot | FirebaseFirestore.DocumentSnapshot): FileMeta | null => 5 | snapshot.exists 6 | ? { 7 | id: snapshot.id, 8 | name: snapshot.get('name'), 9 | type: snapshot.get('type'), 10 | size: snapshot.get('size'), 11 | owner: snapshot.get('owner'), 12 | comments: snapshot.get('comments'), 13 | uploaded: snapshot.get('uploaded')?.toMillis(), 14 | public: snapshot.get('public') 15 | } 16 | : null 17 | 18 | export default snapshotToFileMeta 19 | -------------------------------------------------------------------------------- /lib/snapshotToUser.ts: -------------------------------------------------------------------------------- 1 | import User from 'models/User' 2 | import firebase from './firebase' 3 | 4 | const snapshotToUser = ( 5 | snapshot: firebase.firestore.DocumentSnapshot | FirebaseFirestore.DocumentSnapshot, 6 | includeApiKey: boolean = false 7 | ) => { 8 | if (!snapshot.exists) 9 | return null 10 | 11 | const user: User = { 12 | id: snapshot.id, 13 | slug: snapshot.get('slug'), 14 | name: snapshot.get('name'), 15 | files: snapshot.get('files'), 16 | comments: snapshot.get('comments') 17 | } 18 | 19 | if (includeApiKey) 20 | user.apiKey = snapshot.get('apiKey') 21 | 22 | return user 23 | } 24 | 25 | export default snapshotToUser 26 | -------------------------------------------------------------------------------- /lib/submitComment.ts: -------------------------------------------------------------------------------- 1 | import FileMeta from 'models/FileMeta' 2 | import firebase from './firebase' 3 | 4 | import 'firebase/firestore' 5 | 6 | const { FieldValue } = firebase.firestore 7 | const firestore = firebase.firestore() 8 | 9 | const submitComment = (uid: string, file: FileMeta, body: string) => 10 | firestore.collection(`files/${file.id}/comments`).add({ 11 | from: uid, 12 | body, 13 | date: FieldValue.serverTimestamp() 14 | }) 15 | 16 | export default submitComment 17 | -------------------------------------------------------------------------------- /lib/toSlug.ts: -------------------------------------------------------------------------------- 1 | const toSlug = (string: string) => 2 | string 3 | .trim() 4 | .replace(/[\s\-\+\_]+/g, '-') 5 | .toLowerCase() 6 | 7 | export default toSlug 8 | -------------------------------------------------------------------------------- /lib/uploadFile.ts: -------------------------------------------------------------------------------- 1 | import { getExtension } from 'mime' 2 | 3 | import FileMeta from 'models/FileMeta' 4 | import firebase from './firebase' 5 | import { getIsPublic } from './access' 6 | import newId from './newId' 7 | 8 | import 'firebase/firestore' 9 | import 'firebase/storage' 10 | 11 | const { FieldValue } = firebase.firestore 12 | const firestore = firebase.firestore() 13 | const storage = firebase.storage().ref() 14 | 15 | const uploadFile = async ( 16 | file: File, 17 | owner: string | null, 18 | setProgress: (progress: number) => void 19 | ): Promise => { 20 | const { name, type, size } = file 21 | const isPublic = owner ? getIsPublic() : true 22 | 23 | const indexOfDot = name.lastIndexOf('.') 24 | const id = `${newId()}.${ 25 | ~indexOfDot 26 | ? name.slice(indexOfDot + 1) 27 | : getExtension(type) 28 | }` 29 | 30 | const task = storage.child(id).put(file, { 31 | contentType: type, 32 | contentDisposition: `inline; filename=${JSON.stringify(name)}`, 33 | cacheControl: 'public, max-age=31536000, s-maxage=31536000', 34 | customMetadata: { name, owner } 35 | }) 36 | 37 | task.on('state_changed', ({ bytesTransferred, totalBytes }) => { 38 | setProgress((bytesTransferred / totalBytes) * 90) 39 | }) 40 | 41 | await task 42 | await firestore.doc(`files/${id}`).set({ 43 | name, 44 | type, 45 | size, 46 | owner, 47 | comments: 0, 48 | uploaded: FieldValue.serverTimestamp(), 49 | public: isPublic 50 | }) 51 | 52 | setProgress(100) 53 | 54 | return { id, name, type, size, owner, comments: 0, uploaded: Date.now(), public: isPublic, blob: file } 55 | } 56 | 57 | export default uploadFile 58 | -------------------------------------------------------------------------------- /models/Comment.ts: -------------------------------------------------------------------------------- 1 | export default interface Comment { 2 | id: string 3 | from: string 4 | body: string 5 | date: number 6 | } 7 | -------------------------------------------------------------------------------- /models/CurrentUser.ts: -------------------------------------------------------------------------------- 1 | import User from './User' 2 | import firebase from 'lib/firebase' 3 | 4 | import 'firebase/auth' 5 | 6 | export default interface CurrentUser { 7 | auth: firebase.User 8 | data: User | null 9 | } 10 | -------------------------------------------------------------------------------- /models/FileMeta.ts: -------------------------------------------------------------------------------- 1 | export default interface FileMeta { 2 | id: string 3 | name: string 4 | type: string 5 | size: number 6 | owner: string | null 7 | comments: number 8 | uploaded: number 9 | public: boolean 10 | blob?: Blob 11 | } 12 | -------------------------------------------------------------------------------- /models/FileType.ts: -------------------------------------------------------------------------------- 1 | enum FileType { 2 | Image = 'image', 3 | Video = 'video', 4 | Audio = 'audio', 5 | PDF = 'pdf', 6 | Other = 'other' 7 | } 8 | 9 | export default FileType 10 | -------------------------------------------------------------------------------- /models/User.ts: -------------------------------------------------------------------------------- 1 | export default interface User { 2 | id: string 3 | slug: string 4 | apiKey?: string | null 5 | name: string 6 | files: number 7 | comments: number 8 | } 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | declare module '*.mdx' { 6 | const Document: React.FC 7 | export default Document 8 | } 9 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { getCSP, SELF, DATA, INLINE, NONE } = require('csp-header') 2 | 3 | const IS_PRODUCTION = process.env.NODE_ENV === 'production' 4 | const ORIGIN = IS_PRODUCTION ? 'https://filein.io' : 'http://localhost:3000' 5 | 6 | const plugins = [ 7 | [require('next-optimized-images')], 8 | [require('@next/mdx')()] 9 | ] 10 | 11 | const config = { 12 | headers: () => [ 13 | { 14 | source: '/(.*)', 15 | headers: [ 16 | { key: 'Access-Control-Allow-Origin', value: ORIGIN }, 17 | { 18 | key: 'Content-Security-Policy', 19 | value: getCSP({ 20 | directives: { 21 | 'default-src': [SELF], 22 | 'base-uri': [SELF], 23 | 'font-src': [ 24 | SELF, 25 | 'https://fonts.gstatic.com' 26 | ], 27 | 'frame-src': [ 28 | SELF, 29 | 'https://file-in.firebaseapp.com', 30 | 'https://app.hubspot.com' 31 | ], 32 | 'frame-ancestors': [SELF], 33 | 'img-src': [ 34 | SELF, 35 | DATA, 36 | 'https://storage.googleapis.com', 37 | 'https://platform.slack-edge.com', 38 | 'https://forms.hsforms.com', 39 | 'https://track.hubspot.com' 40 | ], 41 | 'media-src': [ 42 | SELF, 43 | DATA, 44 | 'https://storage.googleapis.com' 45 | ], 46 | 'script-src': [ 47 | SELF, 48 | ...IS_PRODUCTION ? [] : ["'unsafe-eval'"], 49 | 'https://apis.google.com', 50 | 'https://js.hs-scripts.com', 51 | 'https://js.hs-analytics.net', 52 | 'https://js.hs-banner.com', 53 | 'https://js.usemessages.com', 54 | 'https://js.hscollectedforms.net' 55 | ], 56 | 'script-src-attr': [NONE], 57 | 'style-src': [ 58 | SELF, 59 | INLINE, 60 | 'https://fonts.googleapis.com' 61 | ], 62 | 'connect-src': [ 63 | SELF, 64 | 'https://*.googleapis.com', 65 | 'https://vitals.vercel-insights.com', 66 | 'https://api.hubspot.com', 67 | 'https://forms.hubspot.com' 68 | ], 69 | 'block-all-mixed-content': true, 70 | 'upgrade-insecure-requests': true 71 | } 72 | }) 73 | }, 74 | { key: 'Expect-CT', value: '0' }, 75 | { key: 'Referrer-Policy', value: 'no-referrer' }, 76 | { key: 'Strict-Transport-Security', value: 'max-age=15552000' }, 77 | { key: 'X-Content-Type-Options', value: 'nosniff' }, 78 | { key: 'X-DNS-Prefetch-Control', value: 'off' }, 79 | { key: 'X-Download-Options', value: 'noopen' }, 80 | { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, 81 | { key: 'X-Permitted-Cross-Domain-Policies', value: 'none' }, 82 | { key: 'X-XSS-Protection', value: '0' } 83 | ] 84 | } 85 | ], 86 | redirects: () => [ 87 | { 88 | source: '/slack/install', 89 | destination: process.env.NEXT_PUBLIC_SLACK_INSTALL_URL, 90 | statusCode: 302 91 | } 92 | ] 93 | } 94 | 95 | module.exports = require('next-compose-plugins')(plugins, config) 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "filein", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "next", 6 | "build": "next build", 7 | "start": "next start" 8 | }, 9 | "dependencies": { 10 | "@fortawesome/fontawesome-svg-core": "^1.2.32", 11 | "@fortawesome/free-brands-svg-icons": "^5.15.1", 12 | "@fortawesome/free-solid-svg-icons": "^5.15.1", 13 | "@fortawesome/react-fontawesome": "^0.1.13", 14 | "@mdx-js/loader": "^1.6.22", 15 | "@next/mdx": "^10.0.4", 16 | "balloon-css": "^1.2.0", 17 | "classnames": "^2.2.6", 18 | "copy-to-clipboard": "^3.3.1", 19 | "csp-header": "^2.1.1", 20 | "file-saver": "^2.0.5", 21 | "firebase": "^8.2.1", 22 | "firebase-admin": "^9.4.2", 23 | "mime": "^2.4.7", 24 | "nanoid": "^3.1.20", 25 | "next": "^10.0.3", 26 | "next-compose-plugins": "^2.2.1", 27 | "next-optimized-images": "^3.0.0-canary.10", 28 | "nextjs-progressbar": "^0.0.7", 29 | "react": "^17.0.1", 30 | "react-dom": "^17.0.1", 31 | "react-dropzone": "^11.2.4", 32 | "react-pdf": "^5.1.0", 33 | "react-toastify": "^6.2.0", 34 | "recoil": "^0.1.2", 35 | "sass": "^1.30.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/preset-env": "^7.12.11", 39 | "@babel/preset-react": "^7.12.10", 40 | "@types/classnames": "^2.2.11", 41 | "@types/file-saver": "^2.0.1", 42 | "@types/mime": "^2.0.3", 43 | "@types/node": "^14.14.14", 44 | "@types/react": "^17.0.0", 45 | "@types/react-pdf": "^5.0.0", 46 | "typescript": "^4.1.3" 47 | }, 48 | "private": true 49 | } 50 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next' 2 | import { useRouter } from 'next/router' 3 | 4 | import Head from 'components/Head' 5 | import Gradient from 'components/Gradient' 6 | import Footer from 'components/Footer' 7 | 8 | import styles from 'styles/NotFound.module.scss' 9 | 10 | const NotFound: NextPage = () => ( 11 |
12 | 18 | 19 |

404

20 |

21 | Oh no! Are you lost? 22 |

23 |
24 |
25 |
26 | ) 27 | 28 | export default NotFound 29 | -------------------------------------------------------------------------------- /pages/[id].tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from 'react' 2 | import { NextPage, GetStaticPaths, GetStaticProps } from 'next' 3 | import Router from 'next/router' 4 | import { useSetRecoilState } from 'recoil' 5 | import Link from 'next/link' 6 | import copy from 'copy-to-clipboard' 7 | import { toast } from 'react-toastify' 8 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 9 | import { faChevronRight, faDownload, faLink, faTrash } from '@fortawesome/free-solid-svg-icons' 10 | import cx from 'classnames' 11 | 12 | import User from 'models/User' 13 | import FileMeta from 'models/FileMeta' 14 | import getFileIds from 'lib/getFileIds' 15 | import getUser from 'lib/getUser' 16 | import getFile from 'lib/getFile' 17 | import getFileUrl from 'lib/getFileUrl' 18 | import _deleteFile from 'lib/deleteFile' 19 | import { REVALIDATE } from 'lib/constants' 20 | import usersState from 'state/users' 21 | import useCurrentUser from 'hooks/useCurrentUser' 22 | import Head from 'components/Head' 23 | import FileHead from 'components/FileHead' 24 | import Gradient from 'components/Gradient' 25 | import FilePreview from 'components/FilePreview' 26 | import EditFileName from 'components/EditFileName' 27 | import AccessToggle from 'components/AccessToggle' 28 | import Comments from 'components/Comments' 29 | import Footer from 'components/Footer' 30 | 31 | import styles from 'styles/FilePage.module.scss' 32 | 33 | interface FilePageProps { 34 | file: FileMeta 35 | owner: User | null 36 | } 37 | 38 | const FilePage: NextPage = ({ file: _file, owner }) => { 39 | const setUsers = useSetRecoilState(usersState) 40 | const [file, setFile] = useState(_file) 41 | 42 | const currentUser = useCurrentUser() 43 | 44 | const url = getFileUrl(file) 45 | const isOwner = currentUser?.auth 46 | ? currentUser.auth.uid === file.owner 47 | : false 48 | 49 | const download = useCallback(() => { 50 | saveAs(getFileUrl(file, true), file.name) 51 | }, [file]) 52 | 53 | const copyLink = useCallback(() => { 54 | if (!url) 55 | return 56 | 57 | copy(url) 58 | toast.success('Copied file to clipboard') 59 | }, [url]) 60 | 61 | const deleteFile = useCallback(() => { 62 | if (!window.confirm('Are you sure? You cannot restore a deleted file.')) 63 | return 64 | 65 | _deleteFile(file) 66 | .catch(({ message }) => toast.error(message)) 67 | 68 | Router.push('/') 69 | }, [file]) 70 | 71 | useEffect(() => { 72 | if (owner) 73 | setUsers(users => ({ 74 | ...users, 75 | [owner.id]: users[owner.id] ?? owner 76 | })) 77 | }, [owner, setUsers]) 78 | 79 | return ( 80 |
81 | 86 | 87 | 88 | 89 |
90 |
91 |
92 | {isOwner 93 | ? 94 | :

{file.name}

95 | } 96 |

97 | Uploaded by {owner 98 | ? ( 99 | 100 | 101 | {owner.name} 102 | 106 | 107 | 108 | ) 109 | : anonymous 110 | } 111 |

112 |
113 |
114 |
115 | 122 | 129 | {isOwner && ( 130 | 137 | )} 138 |
139 | {(isOwner || !file.public) && ( 140 | 146 | )} 147 |
148 |
149 | 150 |
151 |
152 |
153 |
154 | ) 155 | } 156 | 157 | export const getStaticPaths: GetStaticPaths = async () => ({ 158 | paths: (await getFileIds()).map(id => ({ 159 | params: { id } 160 | })), 161 | fallback: 'blocking' 162 | }) 163 | 164 | export const getStaticProps: GetStaticProps = async ({ params }) => { 165 | const file = await getFile(params.id as string) 166 | 167 | if (!file) 168 | return { notFound: true } 169 | 170 | return { 171 | props: { file, owner: file.owner && await getUser(file.owner) }, 172 | revalidate: REVALIDATE 173 | } 174 | } 175 | 176 | export default FilePage 177 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next' 2 | import { AppProps } from 'next/app' 3 | import Head from 'next/head' 4 | import { RecoilRoot } from 'recoil' 5 | import { ToastContainer } from 'react-toastify' 6 | import { config } from '@fortawesome/fontawesome-svg-core' 7 | 8 | import UploadDrop from 'components/UploadDrop' 9 | import UploadFile from 'components/UploadFile' 10 | import CurrentFile from 'components/CurrentFile' 11 | import Navbar from 'components/Navbar' 12 | 13 | import { src as appleTouchIcon } from 'images/apple-touch-icon.png' 14 | import { src as icon32 } from 'images/icon-32x32.png' 15 | import { src as icon16 } from 'images/icon-16x16.png' 16 | 17 | import 'styles/global.scss' 18 | 19 | config.autoAddCss = false 20 | 21 | const App: NextPage = ({ Component, pageProps }) => ( 22 | <> 23 | 24 | 25 | 26 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | 57 | export default App 58 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default class CustomDocument extends Document { 4 | render = () => ( 5 | 6 | 7 | 8 |
9 | 10 |