diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5765043 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vercel +drift.sqlite \ No newline at end of file diff --git a/client/components/auth/auth.module.css b/client/components/auth/auth.module.css index 4398333..0b1da78 100644 --- a/client/components/auth/auth.module.css +++ b/client/components/auth/auth.module.css @@ -1,22 +1,22 @@ .container { - padding: 2rem 2rem; - border-radius: var(--border-radius); - box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); + padding: 2rem 2rem; + border-radius: var(--radius); + box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); } .form { - display: grid; - place-items: center; + display: grid; + place-items: center; } .formGroup { - display: flex; - flex-direction: column; - place-items: center; - gap: 10px; + display: flex; + flex-direction: column; + place-items: center; + gap: 10px; } .formContentSpace { - margin-bottom: 1rem; - text-align: center; -} \ No newline at end of file + margin-bottom: 1rem; + text-align: center; +} diff --git a/client/components/badges/created-ago-badge/index.tsx b/client/components/badges/created-ago-badge/index.tsx new file mode 100644 index 0000000..88f63d0 --- /dev/null +++ b/client/components/badges/created-ago-badge/index.tsx @@ -0,0 +1,22 @@ +import { Badge, Tooltip } from "@geist-ui/core"; +import { timeAgo } from "@lib/time-ago"; +import { useMemo, useState, useEffect } from "react"; + +const CreatedAgoBadge = ({ createdAt }: { + createdAt: string | Date; +}) => { + const createdDate = useMemo(() => new Date(createdAt), [createdAt]) + const [time, setTimeAgo] = useState(timeAgo(createdDate)) + + useEffect(() => { + const interval = setInterval(() => { + setTimeAgo(timeAgo(createdDate)) + }, 1000) + return () => clearInterval(interval) + }, [createdDate]) + + const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}` + return ( Created {time}) +} + +export default CreatedAgoBadge diff --git a/client/components/badges/expiration-badge/index.tsx b/client/components/badges/expiration-badge/index.tsx new file mode 100644 index 0000000..d089378 --- /dev/null +++ b/client/components/badges/expiration-badge/index.tsx @@ -0,0 +1,58 @@ +import { Badge, Tooltip } from "@geist-ui/core"; +import { timeUntil } from "@lib/time-ago"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +const ExpirationBadge = ({ + postExpirationDate, + onExpires +}: { + postExpirationDate: Date | string | null + onExpires?: () => void +}) => { + const expirationDate = useMemo(() => postExpirationDate ? new Date(postExpirationDate) : null, [postExpirationDate]) + const [timeUntilString, setTimeUntil] = useState(expirationDate ? timeUntil(expirationDate) : null); + + useEffect(() => { + let interval: NodeJS.Timer | null = null; + if (expirationDate) { + interval = setInterval(() => { + if (expirationDate) { + setTimeUntil(timeUntil(expirationDate)) + } + }, 1000) + } + + return () => { + if (interval) { + clearInterval(interval) + } + } + }, [expirationDate]) + + const isExpired = useMemo(() => { + return expirationDate && new Date(expirationDate) < new Date() + }, [expirationDate]) + + useEffect(() => { + if (isExpired) { + if (onExpires) { + onExpires(); + } + } + }, [isExpired, onExpires]) + + if (!expirationDate) { + return null; + } + + return ( + + + {isExpired ? "Expired" : `Expires ${timeUntilString}`} + + + ) +} + +export default ExpirationBadge \ No newline at end of file diff --git a/client/components/visibility-badge/index.tsx b/client/components/badges/visibility-badge/index.tsx similarity index 100% rename from client/components/visibility-badge/index.tsx rename to client/components/badges/visibility-badge/index.tsx diff --git a/client/components/new-post/expiration-modal/index.tsx b/client/components/new-post/expiration-modal/index.tsx new file mode 100644 index 0000000..cd70d58 --- /dev/null +++ b/client/components/new-post/expiration-modal/index.tsx @@ -0,0 +1,61 @@ + +import { Modal, Note, Spacer, Input } from "@geist-ui/core" +import { useCallback, useState } from "react" +import DatePicker from 'react-datepicker'; +// import "react-datepicker/dist/react-datepicker.css"; +import styles from './modal.module.css' + +type Props = { + isOpen: boolean + onClose: () => void + onSubmit: (expiresAt: Date) => void +} + +const ExpirationModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify }: Props) => { + const [error, setError] = useState() + const [date, setDate] = useState(new Date()); + const onSubmit = () => { + onSubmitAfterVerify(date) + } + + const onDateChange = (date: Date) => { + setDate(date) + } + + const CustomTimeInput = ({ value, onChange }: { + date: Date, + value: string, + onChange: (date: string) => void + }) => { + return ( + onChange(e.target.value)} + htmlType="time" + />) + } + + return (<> + {/* TODO: investigate disableBackdropClick not updating state? */} + { + Enter an expiration time + + } + showTimeInput={true} + // @ts-ignore + customTimeInput={} + timeInputLabel="Time:" + dateFormat="MM/dd/yyyy h:mm aa" + /> + + Cancel + Submit + } + ) +} + + +export default ExpirationModal \ No newline at end of file diff --git a/client/components/new-post/expiration-modal/modal.module.css b/client/components/new-post/expiration-modal/modal.module.css new file mode 100644 index 0000000..93dd09a --- /dev/null +++ b/client/components/new-post/expiration-modal/modal.module.css @@ -0,0 +1,4 @@ +.wrapper { + /* For date picker */ + overflow: visible !important; +} diff --git a/client/components/new-post/index.tsx b/client/components/new-post/index.tsx index f3240b9..49f7762 100644 --- a/client/components/new-post/index.tsx +++ b/client/components/new-post/index.tsx @@ -1,21 +1,22 @@ -import { Button, useToasts, ButtonDropdown } from '@geist-ui/core' +import { Button, useToasts, ButtonDropdown, Toggle, Input, useClickAway } from '@geist-ui/core' import { useRouter } from 'next/router'; -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import generateUUID from '@lib/generate-uuid'; import FileDropzone from './drag-and-drop'; import styles from './post.module.css' import Title from './title'; import Cookies from 'js-cookie' import type { PostVisibility, Document as DocumentType } from '@lib/types'; -import PasswordModal from './password'; +import PasswordModal from './password-modal'; import getPostPath from '@lib/get-post-path'; import EditDocumentList from '@components/edit-document-list'; import { ChangeEvent } from 'react'; - +import DatePicker from 'react-datepicker'; const Post = () => { const { setToast } = useToasts() const router = useRouter(); const [title, setTitle] = useState() + const [expiresAt, setExpiresAt] = useState(null) const [docs, setDocs] = useState([{ title: '', @@ -24,7 +25,8 @@ const Post = () => { }]) const [passwordModalVisible, setPasswordModalVisible] = useState(false) - const sendRequest = useCallback(async (url: string, data: { visibility?: PostVisibility, title?: string, files?: DocumentType[], password?: string, userId: string }) => { + + const sendRequest = useCallback(async (url: string, data: { expiresAt: Date | null, visibility?: PostVisibility, title?: string, files?: DocumentType[], password?: string, userId: string }) => { const res = await fetch(url, { method: "POST", headers: { @@ -55,11 +57,14 @@ const Post = () => { const [isSubmitting, setSubmitting] = useState(false) - const onSubmit = async (visibility: PostVisibility, password?: string) => { + const onSubmit = useCallback(async (visibility: PostVisibility, password?: string) => { if (visibility === 'protected' && !password) { setPasswordModalVisible(true) return } + + setPasswordModalVisible(false) + setSubmitting(true) let hasErrored = false @@ -91,15 +96,20 @@ const Post = () => { files: docs, visibility, password, - userId: Cookies.get('drift-userid') || '' + userId: Cookies.get('drift-userid') || '', + expiresAt }) - } + }, [docs, expiresAt, sendRequest, setToast, title]) const onClosePasswordModal = () => { setPasswordModalVisible(false) setSubmitting(false) } + const submitPassword = useCallback((password) => onSubmit('protected', password), [onSubmit]) + + const onChangeExpiration = useCallback((date) => setExpiresAt(date), []) + const onChangeTitle = useCallback((e: ChangeEvent) => { setTitle(e.target.value) }, [setTitle]) @@ -117,7 +127,6 @@ const Post = () => { setDocs((docs) => docs.filter((_, index) => i !== index)) }, [setDocs]) - const uploadDocs = useCallback((files: DocumentType[]) => { // if no title is set and the only document is empty, const isFirstDocEmpty = docs.length <= 1 && (docs.length ? docs[0].title === '' : true) @@ -174,15 +183,36 @@ const Post = () => { > Add a File - - - onSubmit('private')}>Create Private - onSubmit('public')} >Create Public - onSubmit('unlisted')} >Create Unlisted - onSubmit('protected')} >Create with Password - - onSubmit('protected', password)} /> +
+ {} + placeholderText="Won't expire" + selected={expiresAt} + showTimeInput={true} + // customTimeInput={} + timeInputLabel="Time:" + dateFormat="MM/dd/yyyy h:mm aa" + className={styles.datePicker} + clearButtonTitle={"Clear"} + // TODO: investigate why this causes margin shift if true + enableTabLoop={false} + minDate={new Date()} + />} + + onSubmit('private')}>Create Private + onSubmit('public')} >Create Public + onSubmit('unlisted')} >Create Unlisted + onSubmit('protected')} >Create with Password + +
+ + {/* */} ) } diff --git a/client/components/new-post/password/index.tsx b/client/components/new-post/password-modal/index.tsx similarity index 92% rename from client/components/new-post/password/index.tsx rename to client/components/new-post/password-modal/index.tsx index 9ab7a8f..040f6af 100644 --- a/client/components/new-post/password/index.tsx +++ b/client/components/new-post/password-modal/index.tsx @@ -29,7 +29,9 @@ const PasswordModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify, creatin } return (<> - { + {/* TODO: investigate disableBackdropClick not updating state? */} + + { Enter a password {!error && creating && diff --git a/client/components/new-post/post.module.css b/client/components/new-post/post.module.css index 1a57424..77e4b07 100644 --- a/client/components/new-post/post.module.css +++ b/client/components/new-post/post.module.css @@ -6,6 +6,10 @@ margin-top: var(--gap-double); } +.datePicker { + flex: 1; +} + .title { display: flex; flex-direction: row; diff --git a/client/components/post-list/index.tsx b/client/components/post-list/index.tsx index 6918580..ecd6c81 100644 --- a/client/components/post-list/index.tsx +++ b/client/components/post-list/index.tsx @@ -21,7 +21,6 @@ const PostList = ({ morePosts, initialPosts, error }: Props) => { const [posts, setPosts] = useState(initialPosts) const [searching, setSearching] = useState(false) const [hasMorePosts, setHasMorePosts] = useState(morePosts) - const loadMoreClick = useCallback((e: React.MouseEvent) => { e.preventDefault() if (hasMorePosts) { diff --git a/client/components/post-list/list-item.tsx b/client/components/post-list/list-item.tsx index 809fae8..a67baa0 100644 --- a/client/components/post-list/list-item.tsx +++ b/client/components/post-list/list-item.tsx @@ -1,30 +1,21 @@ import NextLink from "next/link" import { useEffect, useMemo, useState } from "react" -import timeAgo from "@lib/time-ago" -import VisibilityBadge from "../visibility-badge" +import { timeAgo } from "@lib/time-ago" +import VisibilityBadge from "../badges/visibility-badge" import getPostPath from "@lib/get-post-path" import { Link, Text, Card, Tooltip, Divider, Badge, Button } from "@geist-ui/core" import { File, Post } from "@lib/types" import FadeIn from "@components/fade-in" import Trash from "@geist-ui/icons/trash" import Cookies from "js-cookie" +import ExpirationBadge from "@components/badges/expiration-badge" +import CreatedAgoBadge from "@components/badges/created-ago-badge" // TODO: isOwner should default to false so this can be used generically const ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?: boolean, deletePost: () => void }) => { - const createdDate = useMemo(() => new Date(post.createdAt), [post.createdAt]) - const [time, setTimeAgo] = useState(timeAgo(createdDate)) - useEffect(() => { - const interval = setInterval(() => { - setTimeAgo(timeAgo(createdDate)) - }, 10000) - return () => clearInterval(interval) - }, [createdDate]) - - const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}` return (
  • - @@ -38,11 +29,14 @@ const ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?: - {time} + {post.files.length === 1 ? "1 file" : `${post.files.length} files`} + + + {isOwner && diff --git a/client/lib/get-post-path.ts b/client/lib/get-post-path.ts index 5235cdf..76cac64 100644 --- a/client/lib/get-post-path.ts +++ b/client/lib/get-post-path.ts @@ -9,5 +9,8 @@ export default function getPostPath(visibility: PostVisibility, id: string) { case "unlisted": case "public": return `/post/${id}` + default: + console.error(`Unknown visibility: ${visibility}`) + return `/post/${id}` } } diff --git a/client/lib/time-ago.ts b/client/lib/time-ago.ts index 46a14e8..66e452c 100644 --- a/client/lib/time-ago.ts +++ b/client/lib/time-ago.ts @@ -29,7 +29,6 @@ const getDuration = (timeAgoInSeconds: number) => { } } -// Calculate const timeAgo = (date: Date) => { const timeAgoInSeconds = Math.floor( (new Date().getTime() - new Date(date).getTime()) / 1000 @@ -40,4 +39,14 @@ const timeAgo = (date: Date) => { return `${interval} ${epoch}${suffix} ago` } -export default timeAgo +const timeUntil = (date: Date) => { + const timeUntilInSeconds = Math.floor( + (new Date(date).getTime() - new Date().getTime()) / 1000 + ) + const { interval, epoch } = getDuration(timeUntilInSeconds) + const suffix = interval === 1 ? "" : "s" + + return `in ${interval} ${epoch}${suffix}` +} + +export { timeAgo, timeUntil } diff --git a/client/lib/types.d.ts b/client/lib/types.d.ts index cf35bb3..ef9e2f8 100644 --- a/client/lib/types.d.ts +++ b/client/lib/types.d.ts @@ -24,6 +24,7 @@ export type Post = { files: Files createdAt: string users?: User[] + expiresAt: Date | string | null } type User = { diff --git a/client/package.json b/client/package.json index cd567a2..653753e 100644 --- a/client/package.json +++ b/client/package.json @@ -30,6 +30,7 @@ "preact": "^10.6.6", "prism-react-renderer": "^1.3.1", "react": "17.0.2", + "react-datepicker": "^4.7.0", "react-dom": "17.0.2", "react-dropzone": "^12.0.4", "react-loading-skeleton": "^3.0.3", @@ -48,6 +49,8 @@ "@types/node": "17.0.21", "@types/nprogress": "^0.2.0", "@types/react": "17.0.39", + "@types/react-datepicker": "^4.3.4", + "@types/react-datetime-picker": "^3.4.1", "@types/react-dom": "^17.0.14", "@types/react-syntax-highlighter": "^13.5.2", "eslint": "8.10.0", diff --git a/client/pages/_document.tsx b/client/pages/_document.tsx index 4f43e76..4acbd87 100644 --- a/client/pages/_document.tsx +++ b/client/pages/_document.tsx @@ -28,4 +28,4 @@ class MyDocument extends Document { } } -export default MyDocument \ No newline at end of file +export default MyDocument diff --git a/client/pages/_middleware.tsx b/client/pages/_middleware.tsx index b1a856e..dbcbe8e 100644 --- a/client/pages/_middleware.tsx +++ b/client/pages/_middleware.tsx @@ -1,6 +1,6 @@ -import { NextFetchEvent, NextRequest, NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' -const PUBLIC_FILE = /.(.*)$/ +// const PUBLIC_FILE = /.(.*)$/ export function middleware(req: NextRequest) { const pathname = req.nextUrl.pathname diff --git a/client/pages/expired.tsx b/client/pages/expired.tsx new file mode 100644 index 0000000..a1adcff --- /dev/null +++ b/client/pages/expired.tsx @@ -0,0 +1,19 @@ +import Header from "@components/header" +import { Note, Page, Text } from "@geist-ui/core" +import styles from '@styles/Home.module.css' + +const Expired = () => { + return ( + +
    + + + Error: The drift you're trying to view has expired. + + + + + ) +} + +export default Expired diff --git a/client/pages/new.tsx b/client/pages/new.tsx index e8cd5d3..2a8c3f3 100644 --- a/client/pages/new.tsx +++ b/client/pages/new.tsx @@ -3,12 +3,17 @@ import NewPost from '@components/new-post' import Header from '@components/header' import PageSeo from '@components/page-seo' import { Page } from '@geist-ui/core' +import Head from 'next/head' const New = () => { return ( - + + {/* */} + {/* eslint-disable-next-line @next/next/no-css-tags */} + +
    diff --git a/client/pages/post/protected/[id].tsx b/client/pages/post/protected/[id].tsx index d34e970..5dcbdc2 100644 --- a/client/pages/post/protected/[id].tsx +++ b/client/pages/post/protected/[id].tsx @@ -1,7 +1,7 @@ import { Page, useToasts } from '@geist-ui/core'; import type { Post } from "@lib/types"; -import PasswordModal from "@components/new-post/password"; +import PasswordModal from "@components/new-post/password-modal"; import { useEffect, useState } from "react"; import { useRouter } from "next/router"; import Cookies from "js-cookie"; @@ -70,7 +70,9 @@ const Post = () => { } if (!post) { - return + return + + } return () diff --git a/client/public/css/react-datepicker.css b/client/public/css/react-datepicker.css new file mode 100644 index 0000000..97949a9 --- /dev/null +++ b/client/public/css/react-datepicker.css @@ -0,0 +1,372 @@ +.react-datepicker__year-read-view--down-arrow, +.react-datepicker__month-read-view--down-arrow, +.react-datepicker__month-year-read-view--down-arrow, +.react-datepicker__navigation-icon::before { + border-color: var(--light-gray); + border-style: solid; + border-width: 3px 3px 0 0; + content: ""; + display: block; + height: 9px; + position: absolute; + top: 6px; + width: 9px; +} +.react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle, +.react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle { + margin-left: -4px; + position: absolute; + width: 0; +} + +.react-datepicker-wrapper { + display: inline-block; + padding: 0; + border: 0; +} + +.react-datepicker { + font-family: var(--font-sans); + font-size: 0.8rem; + background-color: var(--bg); + color: var(--fg); + border: 1px solid var(--gray); + border-radius: var(--radius); + display: inline-block; + position: relative; +} + +.react-datepicker--time-only .react-datepicker__triangle { + left: 35px; +} +.react-datepicker--time-only .react-datepicker__time-container { + border-left: 0; +} +.react-datepicker--time-only .react-datepicker__time, +.react-datepicker--time-only .react-datepicker__time-box { + border-radius: var(--radius); + border-radius: var(--radius); +} + +.react-datepicker__triangle { + position: absolute; + left: 50px; +} + +.react-datepicker-popper { + z-index: 1; +} +.react-datepicker-popper[data-placement^="bottom"] { + padding-top: 10px; +} +.react-datepicker-popper[data-placement="bottom-end"] + .react-datepicker__triangle, +.react-datepicker-popper[data-placement="top-end"] .react-datepicker__triangle { + left: auto; + right: 50px; +} +.react-datepicker-popper[data-placement^="top"] { + padding-bottom: 10px; +} +.react-datepicker-popper[data-placement^="right"] { + padding-left: 8px; +} +.react-datepicker-popper[data-placement^="right"] .react-datepicker__triangle { + left: auto; + right: 42px; +} +.react-datepicker-popper[data-placement^="left"] { + padding-right: 8px; +} +.react-datepicker-popper[data-placement^="left"] .react-datepicker__triangle { + left: 42px; + right: auto; +} + +.react-datepicker__header { + text-align: center; + background-color: var(--bg); + border-bottom: 1px solid var(--gray); + border-top-left-radius: var(--radius); + border-top-right-radius: var(--radius); + padding: 8px 0; + position: relative; +} + +.react-datepicker__header--time { + padding-bottom: 8px; + padding-left: 5px; + padding-right: 5px; +} + +.react-datepicker__year-dropdown-container--select, +.react-datepicker__month-dropdown-container--select, +.react-datepicker__month-year-dropdown-container--select, +.react-datepicker__year-dropdown-container--scroll, +.react-datepicker__month-dropdown-container--scroll, +.react-datepicker__month-year-dropdown-container--scroll { + display: inline-block; + margin: 0 2px; +} + +.react-datepicker__current-month, +.react-datepicker-time__header, +.react-datepicker-year-header { + margin-top: 0; + font-weight: bold; + font-size: 0.944rem; +} + +.react-datepicker-time__header { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.react-datepicker__navigation { + align-items: center; + background: none; + display: flex; + justify-content: center; + text-align: center; + cursor: pointer; + position: absolute; + top: 2px; + padding: 0; + border: none; + z-index: 1; + height: 32px; + width: 32px; + text-indent: -999em; + overflow: hidden; +} +.react-datepicker__navigation--previous { + left: 2px; +} +.react-datepicker__navigation--next { + right: 2px; +} +.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button) { + right: 85px; +} +.react-datepicker__navigation--years { + position: relative; + top: 0; + display: block; + margin-left: auto; + margin-right: auto; +} +.react-datepicker__navigation--years-previous { + top: 4px; +} +.react-datepicker__navigation--years-upcoming { + top: -4px; +} +.react-datepicker__navigation:hover *::before { + border-color: var(--lighter-gray); +} + +.react-datepicker__navigation-icon { + position: relative; + top: -1px; + font-size: 20px; + width: 0; +} +.react-datepicker__navigation-icon--next { + left: -2px; +} +.react-datepicker__navigation-icon--next::before { + transform: rotate(45deg); + left: -7px; +} +.react-datepicker__navigation-icon--previous { + right: -2px; +} +.react-datepicker__navigation-icon--previous::before { + transform: rotate(225deg); + right: -7px; +} + +.react-datepicker__month-container { + float: left; +} + +.react-datepicker__year { + margin: 0.4rem; + text-align: center; +} +.react-datepicker__year-wrapper { + display: flex; + flex-wrap: wrap; + max-width: 180px; +} +.react-datepicker__year .react-datepicker__year-text { + display: inline-block; + width: 4rem; + margin: 2px; +} + +.react-datepicker__month { + margin: 0.4rem; + text-align: center; +} +.react-datepicker__month .react-datepicker__month-text, +.react-datepicker__month .react-datepicker__quarter-text { + display: inline-block; + width: 4rem; + margin: 2px; +} + +.react-datepicker__input-time-container { + clear: both; + width: 100%; + float: left; + margin: 5px 0 10px 15px; + text-align: left; +} +.react-datepicker__input-time-container .react-datepicker-time__caption { + display: inline-block; +} +.react-datepicker__input-time-container + .react-datepicker-time__input-container { + display: inline-block; +} +.react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input { + display: inline-block; + margin-left: 10px; +} +.react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input { + width: auto; +} +.react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input[type="time"]::-webkit-inner-spin-button, +.react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input[type="time"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +.react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input[type="time"] { + -moz-appearance: textfield; +} +.react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__delimiter { + margin-left: 5px; + display: inline-block; +} + +.react-datepicker__day-names, +.react-datepicker__week { + white-space: nowrap; +} + +.react-datepicker__day-names { + margin-bottom: -8px; +} + +.react-datepicker__day-name, +.react-datepicker__day, +.react-datepicker__time-name { + color: var(--fg); + display: inline-block; + width: 1.7rem; + line-height: 1.7rem; + text-align: center; + margin: 0.166rem; +} +.react-datepicker__day, +.react-datepicker__month-text, +.react-datepicker__quarter-text, +.react-datepicker__year-text { + cursor: pointer; +} +.react-datepicker__day:hover, +.react-datepicker__month-text:hover, +.react-datepicker__quarter-text:hover, +.react-datepicker__year-text:hover { + border-radius: 0.3rem; + background-color: var(--light-gray); +} +.react-datepicker__day--today, +.react-datepicker__month-text--today, +.react-datepicker__quarter-text--today, +.react-datepicker__year-text--today { + font-weight: bold; +} +.react-datepicker__day--highlighted, +.react-datepicker__month-text--highlighted, +.react-datepicker__quarter-text--highlighted, +.react-datepicker__year-text--highlighted { + border-radius: 0.3rem; + background-color: #3dcc4a; + color: var(--fg); +} +.react-datepicker__day--highlighted:hover, +.react-datepicker__month-text--highlighted:hover, +.react-datepicker__quarter-text--highlighted:hover, +.react-datepicker__year-text--highlighted:hover { + background-color: #32be3f; +} + +.react-datepicker__day--selected, +.react-datepicker__day--in-selecting-range, +.react-datepicker__day--in-range, +.react-datepicker__month-text--selected, +.react-datepicker__month-text--in-selecting-range, +.react-datepicker__month-text--in-range, +.react-datepicker__quarter-text--selected, +.react-datepicker__quarter-text--in-selecting-range, +.react-datepicker__quarter-text--in-range, +.react-datepicker__year-text--selected, +.react-datepicker__year-text--in-selecting-range, +.react-datepicker__year-text--in-range { + border-radius: 0.3rem; + background-color: var(--light-gray); + color: var(--fg); +} +.react-datepicker__day--selected:hover { + background-color: var(--gray); +} + +.react-datepicker__day--keyboard-selected, +.react-datepicker__month-text--keyboard-selected, +.react-datepicker__quarter-text--keyboard-selected, +.react-datepicker__year-text--keyboard-selected { + border-radius: 0.3rem; + background-color: var(--light-gray); + color: var(--fg); +} +.react-datepicker__day--keyboard-selected:hover { + background-color: var(--gray); +} + +.react-datepicker__month--selecting-range + .react-datepicker__day--in-range:not(.react-datepicker__day--in-selecting-range, .react-datepicker__month-text--in-selecting-range, .react-datepicker__quarter-text--in-selecting-range, .react-datepicker__year-text--in-selecting-range) { + background-color: var(--bg); + color: var(--fg); +} + +.react-datepicker { + transform: scale(1.15) translateY(-12px); +} + +.react-datepicker__day--disabled { + color: var(--darker-gray); +} + +.react-datepicker__day--disabled:hover { + background-color: transparent; + cursor: not-allowed; +} diff --git a/client/yarn.lock b/client/yarn.lock index 71a90be..a222c35 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -227,6 +227,11 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== +"@popperjs/core@^2.9.2": + version "2.11.4" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503" + integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg== + "@rushstack/eslint-patch@1.0.8": version "1.0.8" resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.0.8.tgz#be3e914e84eacf16dbebd311c0d0b44aa1174c64" @@ -315,6 +320,23 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== +"@types/react-datepicker@^4.3.4": + version "4.3.4" + resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-4.3.4.tgz#1cccf5acfb8672fce08940d1cf69e664500ea63d" + integrity sha512-5nTTz37KdTUgMZ1AAxztMWNtEnIMVRo8oCAEhIv0a6uUqDjvSKaMyPRpBV+8chi6f/A8wlTKJIpojpXca2dx3A== + dependencies: + "@popperjs/core" "^2.9.2" + "@types/react" "*" + date-fns "^2.0.1" + react-popper "^2.2.5" + +"@types/react-datetime-picker@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@types/react-datetime-picker/-/react-datetime-picker-3.4.1.tgz#8acbc3e6f4e69fac0f91be4e920c3efdc28f3ed7" + integrity sha512-JHqB74+8Zq6cY0PTJ6Wi5Pm6qkNUmooyFfW5SiknSY2xJG1UG8+ljyWTZAvgHvj0XpqcWCHqqYUPiAVagnf9Sg== + dependencies: + "@types/react" "*" + "@types/react-dom@^17.0.14": version "17.0.14" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.14.tgz#c8f917156b652ddf807711f5becbd2ab018dea9f" @@ -722,6 +744,11 @@ character-reference-invalid@^1.0.0: optionalDependencies: fsevents "~2.3.2" +classnames@^2.2.6: + version "2.3.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" + integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== + cli-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" @@ -893,6 +920,11 @@ damerau-levenshtein@^1.0.7: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== +date-fns@^2.0.1, date-fns@^2.24.0: + version "2.28.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" + integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== + debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -2306,7 +2338,7 @@ longest-streak@^3.0.0: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.0.1.tgz#c97315b7afa0e7d9525db9a5a2953651432bdc5d" integrity sha512-cHlYSUpL2s7Fb3394mYxwTYj8niTaNHUCLr0qdiCXQfSjfuA7CKofpX2uSwEfFDQ0EB7JcnMnm+GjbqqoinYYg== -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -3577,7 +3609,7 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -prop-types@^15.0.0, prop-types@^15.8.1: +prop-types@^15.0.0, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -3623,6 +3655,18 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-datepicker@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.7.0.tgz#75e03b0a6718b97b84287933307faf2ed5f03cf4" + integrity sha512-FS8KgbwqpxmJBv/bUdA42MYqYZa+fEYcpc746DZiHvVE2nhjrW/dg7c5B5fIUuI8gZET6FOzuDgezNcj568Czw== + dependencies: + "@popperjs/core" "^2.9.2" + classnames "^2.2.6" + date-fns "^2.24.0" + prop-types "^15.7.2" + react-onclickoutside "^6.12.0" + react-popper "^2.2.5" + react-dom@17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" @@ -3641,6 +3685,11 @@ react-dropzone@^12.0.4: file-selector "^0.4.0" prop-types "^15.8.1" +react-fast-compare@^3.0.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" + integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -3676,6 +3725,19 @@ react-markdown@^8.0.0: unist-util-visit "^4.0.0" vfile "^5.0.0" +react-onclickoutside@^6.12.0: + version "6.12.1" + resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.12.1.tgz#92dddd28f55e483a1838c5c2930e051168c1e96b" + integrity sha512-a5Q7CkWznBRUWPmocCvE8b6lEYw1s6+opp/60dCunhO+G6E4tDTO2Sd2jKE+leEnnrLAE2Wj5DlDHNqj5wPv1Q== + +react-popper@^2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.5.tgz#1214ef3cec86330a171671a4fbcbeeb65ee58e96" + integrity sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw== + dependencies: + react-fast-compare "^3.0.1" + warning "^4.0.2" + react-syntax-highlighter@^15.4.5: version "15.4.5" resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-15.4.5.tgz#db900d411d32a65c8e90c39cd64555bf463e712e" @@ -4468,6 +4530,13 @@ walkdir@^0.4.1: resolved "https://registry.yarnpkg.com/walkdir/-/walkdir-0.4.1.tgz#dc119f83f4421df52e3061e514228a2db20afa39" integrity sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ== +warning@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + wcwidth@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" diff --git a/server/src/lib/models/Post.ts b/server/src/lib/models/Post.ts index 07f94c7..e0fc59d 100644 --- a/server/src/lib/models/Post.ts +++ b/server/src/lib/models/Post.ts @@ -73,4 +73,12 @@ export class Post extends Model { @UpdatedAt @Column updatedAt!: Date + + @Column + deletedAt?: Date + + @Column + expiresAt?: Date + + // TODO: deletedBy } diff --git a/server/src/migrations/05_expiring_posts.ts b/server/src/migrations/05_expiring_posts.ts new file mode 100644 index 0000000..0ff1963 --- /dev/null +++ b/server/src/migrations/05_expiring_posts.ts @@ -0,0 +1,12 @@ +"use strict" +import { DataTypes } from "sequelize" +import type { Migration } from "../database" + +export const up: Migration = async ({ context: queryInterface }) => + queryInterface.addColumn("posts", "expiresAt", { + type: DataTypes.DATE, + allowNull: true + }) + +export const down: Migration = async ({ context: queryInterface }) => + await queryInterface.removeColumn("posts", "expiresAt") diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index de1aae4..2c9f02f 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -82,7 +82,7 @@ auth.post( } catch (e) { res.status(401).json({ error: { - message: e.message, + message: e.message } }) } @@ -122,7 +122,7 @@ auth.post( } catch (e) { res.status(401).json({ error: { - message: error, + message: error } }) } diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index c55f97d..4b148e1 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -36,19 +36,12 @@ posts.post( .custom(postVisibilitySchema, "valid visibility") .required(), userId: Joi.string().required(), - password: Joi.string().optional() + password: Joi.string().optional(), + expiresAt: Joi.date().optional() } }), async (req, res, next) => { try { - let hashedPassword: string = "" - if (req.body.visibility === "protected") { - hashedPassword = crypto - .createHash("sha256") - .update(req.body.password) - .digest("hex") - } - // check if all files have titles const files = req.body.files as File[] const fileTitles = files.map((file) => file.title) @@ -61,10 +54,19 @@ posts.post( throw new Error("You must submit at least one file") } + let hashedPassword: string = "" + if (req.body.visibility === "protected") { + hashedPassword = crypto + .createHash("sha256") + .update(req.body.password) + .digest("hex") + } + const newPost = new Post({ title: req.body.title, visibility: req.body.visibility, - password: hashedPassword + password: hashedPassword, + expiresAt: req.body.expiresAt }) await newPost.save() @@ -134,7 +136,7 @@ posts.get("/mine", jwt, async (req: UserJwtRequest, res, next) => { attributes: ["id", "title", "createdAt"] } ], - attributes: ["id", "title", "visibility", "createdAt"] + attributes: ["id", "title", "visibility", "createdAt", "expiresAt"] } ] }) @@ -235,6 +237,15 @@ posts.get( as: "users", attributes: ["id", "username"] } + ], + attributes: [ + "id", + "title", + "visibility", + "createdAt", + "updatedAt", + "deletedAt", + "expiresAt" ] })