From 752b2c0980b1e9c5a9a856166451aaf5a4f1ee85 Mon Sep 17 00:00:00 2001
From: Max Leiter <maxwell.leiter@gmail.com>
Date: Wed, 30 Mar 2022 20:01:24 -0700
Subject: [PATCH 1/3] client/server: add support for expiring posts

---
 .gitignore                                    |   2 +
 client/components/auth/auth.module.css        |  24 +-
 .../badges/created-ago-badge/index.tsx        |  22 ++
 .../badges/expiration-badge/index.tsx         |  58 +++
 .../{ => badges}/visibility-badge/index.tsx   |   0
 .../new-post/expiration-modal/index.tsx       |  61 +++
 .../expiration-modal/modal.module.css         |   4 +
 client/components/new-post/index.tsx          |  64 ++-
 .../{password => password-modal}/index.tsx    |   4 +-
 client/components/new-post/post.module.css    |   4 +
 client/components/post-list/index.tsx         |   1 -
 client/components/post-list/list-item.tsx     |  22 +-
 client/components/post-page/index.tsx         |  45 ++-
 client/lib/get-post-path.ts                   |   3 +
 client/lib/time-ago.ts                        |  13 +-
 client/lib/types.d.ts                         |   1 +
 client/package.json                           |   3 +
 client/pages/_document.tsx                    |   2 +-
 client/pages/_middleware.tsx                  |   4 +-
 client/pages/expired.tsx                      |  19 +
 client/pages/new.tsx                          |   7 +-
 client/pages/post/protected/[id].tsx          |   6 +-
 client/public/css/react-datepicker.css        | 372 ++++++++++++++++++
 client/yarn.lock                              |  73 +++-
 server/src/lib/models/Post.ts                 |   8 +
 server/src/migrations/05_expiring_posts.ts    |  12 +
 server/src/routes/auth.ts                     |   4 +-
 server/src/routes/posts.ts                    |  33 +-
 28 files changed, 793 insertions(+), 78 deletions(-)
 create mode 100644 .gitignore
 create mode 100644 client/components/badges/created-ago-badge/index.tsx
 create mode 100644 client/components/badges/expiration-badge/index.tsx
 rename client/components/{ => badges}/visibility-badge/index.tsx (100%)
 create mode 100644 client/components/new-post/expiration-modal/index.tsx
 create mode 100644 client/components/new-post/expiration-modal/modal.module.css
 rename client/components/new-post/{password => password-modal}/index.tsx (92%)
 create mode 100644 client/pages/expired.tsx
 create mode 100644 client/public/css/react-datepicker.css
 create mode 100644 server/src/migrations/05_expiring_posts.ts

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 (<Badge type="secondary"> <Tooltip text={formattedTime}>Created {time}</Tooltip></Badge>)
+}
+
+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<string | null>(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 (
+        <Badge type={isExpired ? "error" : "warning"}>
+            <Tooltip
+                text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}>
+                {isExpired ? "Expired" : `Expires ${timeUntilString}`}
+            </Tooltip>
+        </Badge>
+    )
+}
+
+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<string>()
+    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 (
+            <Input
+                value={value}
+                onChange={(e) => onChange(e.target.value)}
+                htmlType="time"
+            />)
+    }
+
+    return (<>
+        {/* TODO: investigate disableBackdropClick not updating state? */}
+        {<Modal visible={isOpen} wrapClassName={styles.wrapper} disableBackdropClick={true}>
+            <Modal.Title>Enter an expiration time</Modal.Title>
+            <Modal.Content>
+                <DatePicker
+                    selected={date}
+                    onChange={onDateChange}
+                    customInput={<Input />}
+                    showTimeInput={true}
+                    // @ts-ignore
+                    customTimeInput={<CustomTimeInput />}
+                    timeInputLabel="Time:"
+                    dateFormat="MM/dd/yyyy h:mm aa"
+                />
+            </Modal.Content>
+            <Modal.Action passive onClick={onClose}>Cancel</Modal.Action>
+            <Modal.Action onClick={onSubmit}>Submit</Modal.Action>
+        </Modal>}
+    </>)
+}
+
+
+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<string>()
+    const [expiresAt, setExpiresAt] = useState<Date | null>(null)
 
     const [docs, setDocs] = useState<DocumentType[]>([{
         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<HTMLInputElement>) => {
         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
                 </Button>
-
-                <ButtonDropdown loading={isSubmitting} type="success">
-                    <ButtonDropdown.Item main onClick={() => onSubmit('private')}>Create Private</ButtonDropdown.Item>
-                    <ButtonDropdown.Item onClick={() => onSubmit('public')} >Create Public</ButtonDropdown.Item>
-                    <ButtonDropdown.Item onClick={() => onSubmit('unlisted')} >Create Unlisted</ButtonDropdown.Item>
-                    <ButtonDropdown.Item onClick={() => onSubmit('protected')} >Create with Password</ButtonDropdown.Item>
-                </ButtonDropdown>
-                <PasswordModal isOpen={passwordModalVisible} onClose={onClosePasswordModal} onSubmit={(password) => onSubmit('protected', password)} />
+                <div style={{
+                    display: 'flex',
+                    gap: 'var(--gap)',
+                    alignItems: 'center',
+                }}>
+                    {<DatePicker
+                        onChange={onChangeExpiration}
+                        customInput={<Input label="Expires at" clearable width="300px" height="40px" />}
+                        placeholderText="Won't expire"
+                        selected={expiresAt}
+                        showTimeInput={true}
+                        // customTimeInput={<Input htmlType="time" />}
+                        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()}
+                    />}
+                    <ButtonDropdown loading={isSubmitting} type="success">
+                        <ButtonDropdown.Item main onClick={() => onSubmit('private')}>Create Private</ButtonDropdown.Item>
+                        <ButtonDropdown.Item onClick={() => onSubmit('public')} >Create Public</ButtonDropdown.Item>
+                        <ButtonDropdown.Item onClick={() => onSubmit('unlisted')} >Create Unlisted</ButtonDropdown.Item>
+                        <ButtonDropdown.Item onClick={() => onSubmit('protected')} >Create with Password</ButtonDropdown.Item>
+                    </ButtonDropdown>
+                </div>
             </div>
+            <PasswordModal isOpen={passwordModalVisible} onClose={onClosePasswordModal} onSubmit={submitPassword} />
+            {/* <ExpirationModal isOpen={expirationModalVisibile} onClose={onCloseExpirationModal} onSubmit={submitExpiration} /> */}
         </div>
     )
 }
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 (<>
-        {<Modal visible={isOpen} >
+        {/* TODO: investigate disableBackdropClick not updating state? */}
+
+        {<Modal visible={isOpen} disableBackdropClick={true} >
             <Modal.Title>Enter a password</Modal.Title>
             <Modal.Content>
                 {!error && creating && <Note type="warning" label='Warning'>
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<Post[]>(initialPosts)
     const [searching, setSearching] = useState(false)
     const [hasMorePosts, setHasMorePosts] = useState(morePosts)
-
     const loadMoreClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
         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 (<FadeIn><li key={post.id}>
-
         <Card style={{ overflowY: 'scroll' }}>
             <Card.Body>
                 <Text h3>
@@ -38,11 +29,14 @@ const ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?:
                             <VisibilityBadge visibility={post.visibility} />
                         </span>
                         <span style={{ marginLeft: 'var(--gap)' }}>
-                            <Badge type="secondary"><Tooltip text={formattedTime}>{time}</Tooltip></Badge>
+                            <CreatedAgoBadge createdAt={post.createdAt} />
                         </span>
                         <span style={{ marginLeft: 'var(--gap)' }}>
                             <Badge type="secondary">{post.files.length === 1 ? "1 file" : `${post.files.length} files`}</Badge>
                         </span>
+                        <span style={{ marginLeft: 'var(--gap)' }}>
+                            <ExpirationBadge postExpirationDate={post.expiresAt} />
+                        </span>
                     </div>
                     {isOwner && <span style={{ float: 'right' }}>
                         <Button iconRight={<Trash />} onClick={deletePost} auto />
diff --git a/client/components/post-page/index.tsx b/client/components/post-page/index.tsx
index 9d94792..442c616 100644
--- a/client/components/post-page/index.tsx
+++ b/client/components/post-page/index.tsx
@@ -1,23 +1,28 @@
 import Header from "@components/header/header"
 import PageSeo from "@components/page-seo"
-import VisibilityBadge from "@components/visibility-badge"
+import VisibilityBadge from "@components/badges/visibility-badge"
 import DocumentComponent from '@components/view-document'
 import styles from './post-page.module.css'
 import homeStyles from '@styles/Home.module.css'
 
 import type { File, Post } from "@lib/types"
 import { Page, Button, Text, Badge, Tooltip, Spacer, ButtonDropdown, ButtonGroup, useMediaQuery } from "@geist-ui/core"
-import { useMemo, useState } from "react"
-import timeAgo from "@lib/time-ago"
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { timeAgo, timeUntil } from "@lib/time-ago"
 import Archive from '@geist-ui/icons/archive'
 import FileDropdown from "@components/file-dropdown"
 import ScrollToTop from "@components/scroll-to-top"
+import { useRouter } from "next/router"
+import ExpirationBadge from "@components/badges/expiration-badge"
+import CreatedAgoBadge from "@components/badges/created-ago-badge"
+import Cookies from "js-cookie"
 
 type Props = {
     post: Post
 }
 
 const PostPage = ({ post }: Props) => {
+    const router = useRouter()
     const download = async () => {
         const downloadZip = (await import("client-zip")).downloadZip
         const blob = await downloadZip(post.files.map((file: any) => {
@@ -33,11 +38,23 @@ const PostPage = ({ post }: Props) => {
         link.click()
         link.remove()
     }
-    const createdDate = useMemo(() => new Date(post.createdAt), [post.createdAt])
-    const [time, setTimeAgo] = useState(timeAgo(createdDate))
 
-    const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
+
     const isMobile = useMediaQuery("mobile")
+
+    const isExpired = useMemo(() => {
+        return post.expiresAt && new Date(post.expiresAt) < new Date()
+    }, [post.expiresAt])
+
+    const onExpires = useCallback(() => {
+        const isOwner = post.users ? post.users[0].id === Cookies.get("drift-userid") : false
+
+        if (isExpired && !isOwner) {
+            router.push("/expired")
+            return <></>
+        }
+    }, [isExpired, post.users, router])
+
     return (
         <Page width={"100%"}>
             <PageSeo
@@ -54,13 +71,21 @@ const PostPage = ({ post }: Props) => {
                 <div className={styles.header}>
                     <span className={styles.title}>
                         <Text h3>{post.title}</Text>
-                        <div className={styles.badges}>
+                        <ButtonGroup
+                            vertical={isMobile}
+                            style={{
+                                border: "none",
+                                gap: 'var(--gap-half)',
+                                marginLeft: isMobile ? "0" : "var(--gap-half)",
+                            }}>
                             <VisibilityBadge visibility={post.visibility} />
-                            <Badge type="secondary"><Tooltip text={formattedTime}>{time}</Tooltip></Badge>
-                        </div>
+                            <CreatedAgoBadge createdAt={post.createdAt} />
+                            <ExpirationBadge onExpires={onExpires} postExpirationDate={post.expiresAt} />
+                        </ButtonGroup>
                     </span>
                     <span className={styles.buttons}>
-                        <ButtonGroup vertical={isMobile}>
+                        {/* If it hasn't expired, the badge can be too long */}
+                        <ButtonGroup vertical={isMobile || (post.expiresAt && !isExpired) ? true : false}>
                             <Button auto onClick={download} icon={<Archive />}>
                                 Download as ZIP archive
                             </Button>
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 (
+        <Page>
+            <Header />
+            <Page.Content className={styles.main}>
+                <Note type="error" label={false}>
+                    <Text h4>Error: The drift you&apos;re trying to view has expired.</Text>
+                </Note>
+
+            </Page.Content>
+        </Page>
+    )
+}
+
+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 (
     <Page className={styles.wrapper}>
       <PageSeo title="Drift - New" />
-
+      <Head>
+        {/* <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/react-datepicker/4.7.0/react-datepicker.min.css" /> */}
+        {/* eslint-disable-next-line @next/next/no-css-tags */}
+        <link rel="stylesheet" href="/css/react-datepicker.css" />
+      </Head>
       <Page.Header>
         <Header />
       </Page.Header>
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 <Page><PasswordModal creating={false} onClose={onClose} onSubmit={onSubmit} isOpen={isPasswordModalOpen} /></Page>
+        return <Page>
+            <PasswordModal creating={false} onClose={onClose} onSubmit={onSubmit} isOpen={isPasswordModalOpen} />
+        </Page>
     }
 
     return (<PostPage post={post} />)
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"
 				]
 			})
 

From 9f810378f179f245031bd7ad27e7befd7c2bf6ed Mon Sep 17 00:00:00 2001
From: Max Leiter <maxwell.leiter@gmail.com>
Date: Wed, 30 Mar 2022 20:02:16 -0700
Subject: [PATCH 2/3] remove expiration modal

---
 .../new-post/expiration-modal/index.tsx       | 61 -------------------
 .../expiration-modal/modal.module.css         |  4 --
 client/components/new-post/index.tsx          |  1 -
 3 files changed, 66 deletions(-)
 delete mode 100644 client/components/new-post/expiration-modal/index.tsx
 delete mode 100644 client/components/new-post/expiration-modal/modal.module.css

diff --git a/client/components/new-post/expiration-modal/index.tsx b/client/components/new-post/expiration-modal/index.tsx
deleted file mode 100644
index cd70d58..0000000
--- a/client/components/new-post/expiration-modal/index.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-
-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<string>()
-    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 (
-            <Input
-                value={value}
-                onChange={(e) => onChange(e.target.value)}
-                htmlType="time"
-            />)
-    }
-
-    return (<>
-        {/* TODO: investigate disableBackdropClick not updating state? */}
-        {<Modal visible={isOpen} wrapClassName={styles.wrapper} disableBackdropClick={true}>
-            <Modal.Title>Enter an expiration time</Modal.Title>
-            <Modal.Content>
-                <DatePicker
-                    selected={date}
-                    onChange={onDateChange}
-                    customInput={<Input />}
-                    showTimeInput={true}
-                    // @ts-ignore
-                    customTimeInput={<CustomTimeInput />}
-                    timeInputLabel="Time:"
-                    dateFormat="MM/dd/yyyy h:mm aa"
-                />
-            </Modal.Content>
-            <Modal.Action passive onClick={onClose}>Cancel</Modal.Action>
-            <Modal.Action onClick={onSubmit}>Submit</Modal.Action>
-        </Modal>}
-    </>)
-}
-
-
-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
deleted file mode 100644
index 93dd09a..0000000
--- a/client/components/new-post/expiration-modal/modal.module.css
+++ /dev/null
@@ -1,4 +0,0 @@
-.wrapper {
-	/* For date picker */
-	overflow: visible !important;
-}
diff --git a/client/components/new-post/index.tsx b/client/components/new-post/index.tsx
index 49f7762..0d5ed9e 100644
--- a/client/components/new-post/index.tsx
+++ b/client/components/new-post/index.tsx
@@ -212,7 +212,6 @@ const Post = () => {
                 </div>
             </div>
             <PasswordModal isOpen={passwordModalVisible} onClose={onClosePasswordModal} onSubmit={submitPassword} />
-            {/* <ExpirationModal isOpen={expirationModalVisibile} onClose={onCloseExpirationModal} onSubmit={submitExpiration} /> */}
         </div>
     )
 }

From 93e8b7e1d9134f0f7d01813c77518fd865971221 Mon Sep 17 00:00:00 2001
From: Max Leiter <maxwell.leiter@gmail.com>
Date: Wed, 30 Mar 2022 20:03:57 -0700
Subject: [PATCH 3/3] remove @types/react-datetime-picker

---
 client/components/button/button.module.css | 13 -------------
 client/components/post-page/index.tsx      |  1 -
 client/package.json                        |  1 -
 client/pages/new.tsx                       |  2 +-
 client/yarn.lock                           |  7 -------
 5 files changed, 1 insertion(+), 23 deletions(-)

diff --git a/client/components/button/button.module.css b/client/components/button/button.module.css
index 3569089..e94bb8a 100644
--- a/client/components/button/button.module.css
+++ b/client/components/button/button.module.css
@@ -34,19 +34,6 @@
 	color: var(--fg);
 }
 
-/*   
---bg: #131415;
-  --fg: #fafbfc;
-  --gray: #666;
-  --light-gray: #444;
-  --lighter-gray: #222;
-  --lightest-gray: #1a1a1a;
-  --article-color: #eaeaea;
-  --header-bg: rgba(19, 20, 21, 0.45);
-  --gray-alpha: rgba(255, 255, 255, 0.5);
-  --selection: rgba(255, 255, 255, 0.99);
- */
-
 .primary {
 	background: var(--fg);
 	color: var(--bg);
diff --git a/client/components/post-page/index.tsx b/client/components/post-page/index.tsx
index 442c616..5504281 100644
--- a/client/components/post-page/index.tsx
+++ b/client/components/post-page/index.tsx
@@ -67,7 +67,6 @@ const PostPage = ({ post }: Props) => {
                 <Header />
             </Page.Header>
             <Page.Content className={homeStyles.main}>
-                {/* {!isLoading && <PostFileExplorer files={post.files} />} */}
                 <div className={styles.header}>
                     <span className={styles.title}>
                         <Text h3>{post.title}</Text>
diff --git a/client/package.json b/client/package.json
index 653753e..ab2ceb2 100644
--- a/client/package.json
+++ b/client/package.json
@@ -50,7 +50,6 @@
     "@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/new.tsx b/client/pages/new.tsx
index 2a8c3f3..28cb5dc 100644
--- a/client/pages/new.tsx
+++ b/client/pages/new.tsx
@@ -10,7 +10,7 @@ const New = () => {
     <Page className={styles.wrapper}>
       <PageSeo title="Drift - New" />
       <Head>
-        {/* <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/react-datepicker/4.7.0/react-datepicker.min.css" /> */}
+        {/* TODO: solve this. */}
         {/* eslint-disable-next-line @next/next/no-css-tags */}
         <link rel="stylesheet" href="/css/react-datepicker.css" />
       </Head>
diff --git a/client/yarn.lock b/client/yarn.lock
index a222c35..5538c87 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -330,13 +330,6 @@
     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"