client/server: add the ability to copy a post, view a posts parent

This commit is contained in:
Max Leiter 2022-04-01 22:55:27 -07:00
parent ce01eba9c0
commit b8cdc2cf72
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
18 changed files with 333 additions and 110 deletions

View file

@ -16,7 +16,7 @@ const CreatedAgoBadge = ({ createdAt }: {
}, [createdDate]) }, [createdDate])
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}` const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
return (<Badge type="secondary"> <Tooltip text={formattedTime}>Created {time}</Tooltip></Badge>) return (<Badge type="secondary"> <Tooltip hideArrow text={formattedTime}>Created {time}</Tooltip></Badge>)
} }
export default CreatedAgoBadge export default CreatedAgoBadge

View file

@ -51,6 +51,7 @@ const ExpirationBadge = ({
<Tooltip <Tooltip
text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}> text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}>
{isExpired ? "Expired" : `Expires ${timeUntilString}`} {isExpired ? "Expired" : `Expires ${timeUntilString}`}
hideArrow
</Tooltip> </Tooltip>
</Badge> </Badge>
) )

View file

@ -13,9 +13,11 @@ type Item = File & {
} }
const FileDropdown = ({ const FileDropdown = ({
files files,
isMobile
}: { }: {
files: File[] files: File[],
isMobile: boolean
}) => { }) => {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [items, setItems] = useState<Item[]>([]) const [items, setItems] = useState<Item[]>([])
@ -66,10 +68,11 @@ const FileDropdown = ({
// a list of files with an icon and a title // a list of files with an icon and a title
return ( return (
<> <>
<Button auto onClick={onOpen} className={styles.button} iconRight={<ChevronDown />}> <Button auto onClick={onOpen} className={styles.button} iconRight={<ChevronDown />} style={{ textTransform: 'none' }} >
Jump to {files.length} {files.length === 1 ? 'file' : 'files'} Jump to {files.length} {files.length === 1 ? 'file' : 'files'}
</Button> </Button>
<Popover <Popover
style={{ transform: isMobile ? "translateX(110px)" : "translateX(-75px)" }}
onVisibleChange={changeHandler} onVisibleChange={changeHandler}
content={content} visible={expanded} hideArrow={true} onClick={onClose} /> content={content} visible={expanded} hideArrow={true} onClick={onClose} />
</> </>

View file

@ -1,32 +1,63 @@
import { Button, useToasts, ButtonDropdown, Toggle, Input, useClickAway } from '@geist-ui/core' import { Button, useToasts, ButtonDropdown, Toggle, Input, useClickAway } from '@geist-ui/core'
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useCallback, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import generateUUID from '@lib/generate-uuid'; import generateUUID from '@lib/generate-uuid';
import FileDropzone from './drag-and-drop'; import FileDropzone from './drag-and-drop';
import styles from './post.module.css' import styles from './post.module.css'
import Title from './title'; import Title from './title';
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import type { PostVisibility, Document as DocumentType } from '@lib/types'; import type { Post as PostType, PostVisibility, Document as DocumentType } from '@lib/types';
import PasswordModal from './password-modal'; import PasswordModal from './password-modal';
import getPostPath from '@lib/get-post-path'; import getPostPath from '@lib/get-post-path';
import EditDocumentList from '@components/edit-document-list'; import EditDocumentList from '@components/edit-document-list';
import { ChangeEvent } from 'react'; import { ChangeEvent } from 'react';
import DatePicker from 'react-datepicker'; import DatePicker from 'react-datepicker';
const Post = () => {
const Post = ({
initialPost,
newPostParent
}: {
initialPost?: PostType,
newPostParent?: string
}) => {
const { setToast } = useToasts() const { setToast } = useToasts()
const router = useRouter(); const router = useRouter();
const [title, setTitle] = useState<string>() const [title, setTitle] = useState<string>()
const [expiresAt, setExpiresAt] = useState<Date | null>(null) const [expiresAt, setExpiresAt] = useState<Date | null>(null)
const [docs, setDocs] = useState<DocumentType[]>([{ const emptyDoc = useMemo(() => [{
title: '', title: '',
content: '', content: '',
id: generateUUID() id: generateUUID()
}]) }], [])
const [docs, setDocs] = useState<DocumentType[]>(emptyDoc)
// the /new/from/{id} route fetches an initial post
useEffect(() => {
if (initialPost) {
setTitle(`Copy of ${initialPost.title}`)
setDocs(initialPost.files?.map(doc => ({
title: doc.title,
content: doc.content,
id: doc.id
})) || emptyDoc)
}
}, [emptyDoc, initialPost])
const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const sendRequest = useCallback(async (url: string, data: { expiresAt: Date | null, 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,
parentId?: string
}) => {
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
@ -106,9 +137,10 @@ const Post = () => {
visibility, visibility,
password, password,
userId: Cookies.get('drift-userid') || '', userId: Cookies.get('drift-userid') || '',
expiresAt expiresAt,
parentId: newPostParent
}) })
}, [docs, expiresAt, sendRequest, setToast, title]) }, [docs, expiresAt, newPostParent, sendRequest, setToast, title])
const onClosePasswordModal = () => { const onClosePasswordModal = () => {
setPasswordModalVisible(false) setPasswordModalVisible(false)

View file

@ -0,0 +1,30 @@
.title {
display: flex;
justify-content: space-between;
}
.badges {
display: flex;
gap: var(--gap-half);
}
.buttons {
display: flex;
gap: var(--gap-half);
}
@media screen and (max-width: 700px) {
.badges {
flex-direction: column;
align-items: flex-start;
}
.badges > * {
width: min-content;
}
.title {
flex-direction: column;
gap: var(--gap);
}
}

View file

@ -1,49 +1,58 @@
import NextLink from "next/link" import NextLink from "next/link"
import { useEffect, useMemo, useState } from "react"
import { timeAgo } from "@lib/time-ago"
import VisibilityBadge from "../badges/visibility-badge" import VisibilityBadge from "../badges/visibility-badge"
import getPostPath from "@lib/get-post-path" import getPostPath from "@lib/get-post-path"
import { Link, Text, Card, Tooltip, Divider, Badge, Button } from "@geist-ui/core" import { Link, Text, Card, Tooltip, Divider, Badge, Button } from "@geist-ui/core"
import { File, Post } from "@lib/types" import { File, Post } from "@lib/types"
import FadeIn from "@components/fade-in" import FadeIn from "@components/fade-in"
import Trash from "@geist-ui/icons/trash" import Trash from "@geist-ui/icons/trash"
import Cookies from "js-cookie"
import ExpirationBadge from "@components/badges/expiration-badge" import ExpirationBadge from "@components/badges/expiration-badge"
import CreatedAgoBadge from "@components/badges/created-ago-badge" import CreatedAgoBadge from "@components/badges/created-ago-badge"
import Edit from "@geist-ui/icons/edit"
import { useRouter } from "next/router"
import Parent from '@geist-ui/icons/arrowUpCircle'
import styles from "./list-item.module.css"
// TODO: isOwner should default to false so this can be used generically // 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 ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?: boolean, deletePost: () => void }) => {
const router = useRouter()
const editACopy = () => {
router.push(`/new/from/${post.id}`)
}
return (<FadeIn><li key={post.id}> return (<FadeIn><li key={post.id}>
<Card style={{ overflowY: 'scroll' }}> <Card style={{ overflowY: 'scroll' }}>
<Card.Body> <Card.Body>
<Text h3 style={{ <Text h3 className={styles.title}>
}}>
<NextLink passHref={true} href={getPostPath(post.visibility, post.id)}> <NextLink passHref={true} href={getPostPath(post.visibility, post.id)}>
<Link color marginRight={'var(--gap)'}> <Link color marginRight={'var(--gap)'}>
{post.title} {post.title}
</Link> </Link>
</NextLink> </NextLink>
{isOwner && <span style={{ float: 'right' }}> {isOwner && <span className={styles.buttons}>
<Button iconRight={<Trash />} onClick={deletePost} auto /> {post.parent && <Tooltip text={"View parent"} hideArrow>
<Button
auto
icon={<Parent />}
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))}
/>
</Tooltip>}
<Tooltip text={"Make a copy"} hideArrow>
<Button
auto
iconRight={<Edit />}
onClick={editACopy} />
</Tooltip>
<Tooltip text={"Delete"} hideArrow><Button iconRight={<Trash />} onClick={deletePost} auto /></Tooltip>
</span>} </span>}
</Text> </Text>
<div className={styles.badges}>
<div style={{ display: 'inline-flex' }}>
<span>
<VisibilityBadge visibility={post.visibility} /> <VisibilityBadge visibility={post.visibility} />
</span>
<span style={{ marginLeft: 'var(--gap)' }}>
<CreatedAgoBadge createdAt={post.createdAt} /> <CreatedAgoBadge createdAt={post.createdAt} />
</span>
<span style={{ marginLeft: 'var(--gap)' }}>
<Badge type="secondary">{post.files?.length === 1 ? "1 file" : `${post.files?.length || 0} files`}</Badge> <Badge type="secondary">{post.files?.length === 1 ? "1 file" : `${post.files?.length || 0} files`}</Badge>
</span>
<span style={{ marginLeft: 'var(--gap)' }}>
<ExpirationBadge postExpirationDate={post.expiresAt} /> <ExpirationBadge postExpirationDate={post.expiresAt} />
</span>
</div> </div>
</Card.Body> </Card.Body>

View file

@ -9,12 +9,15 @@ import type { File, Post } from "@lib/types"
import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core" import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Archive from '@geist-ui/icons/archive' import Archive from '@geist-ui/icons/archive'
import Edit from '@geist-ui/icons/edit'
import Parent from '@geist-ui/icons/arrowUpCircle'
import FileDropdown from "@components/file-dropdown" import FileDropdown from "@components/file-dropdown"
import ScrollToTop from "@components/scroll-to-top" import ScrollToTop from "@components/scroll-to-top"
import { useRouter } from "next/router" import { useRouter } from "next/router"
import ExpirationBadge from "@components/badges/expiration-badge" import ExpirationBadge from "@components/badges/expiration-badge"
import CreatedAgoBadge from "@components/badges/created-ago-badge" import CreatedAgoBadge from "@components/badges/created-ago-badge"
import Cookies from "js-cookie" import Cookies from "js-cookie"
import getPostPath from "@lib/get-post-path"
type Props = { type Props = {
post: Post post: Post
@ -27,6 +30,9 @@ const PostPage = ({ post }: Props) => {
const [isExpired, setIsExpired] = useState(post.expiresAt ? new Date(post.expiresAt) < new Date() : null) const [isExpired, setIsExpired] = useState(post.expiresAt ? new Date(post.expiresAt) < new Date() : null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
useEffect(() => { useEffect(() => {
if (isExpired) {
router.push("/expired")
}
const isOwner = post.users ? post.users[0].id === Cookies.get("drift-userid") : false const isOwner = post.users ? post.users[0].id === Cookies.get("drift-userid") : false
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "") const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
@ -46,7 +52,7 @@ const PostPage = ({ post }: Props) => {
return () => { return () => {
if (interval) clearInterval(interval) if (interval) clearInterval(interval)
} }
}, [post.expiresAt, post.users, router]) }, [isExpired, post.expiresAt, post.users, router])
const download = async () => { const download = async () => {
@ -66,6 +72,10 @@ const PostPage = ({ post }: Props) => {
link.remove() link.remove()
} }
const editACopy = () => {
router.push(`/new/from/${post.id}`)
}
if (isLoading) { if (isLoading) {
return <></> return <></>
} }
@ -85,24 +95,34 @@ const PostPage = ({ post }: Props) => {
<div className={styles.header}> <div className={styles.header}>
<span className={styles.title}> <span className={styles.title}>
<Text h3>{post.title}</Text> <Text h3>{post.title}</Text>
<ButtonGroup <span className={styles.badges}>
vertical={isMobile}
style={{
border: "none",
gap: 'var(--gap-half)',
marginLeft: isMobile ? "0" : "var(--gap-half)",
}}>
<VisibilityBadge visibility={post.visibility} /> <VisibilityBadge visibility={post.visibility} />
<CreatedAgoBadge createdAt={post.createdAt} /> <CreatedAgoBadge createdAt={post.createdAt} />
<ExpirationBadge postExpirationDate={post.expiresAt} /> <ExpirationBadge postExpirationDate={post.expiresAt} />
</ButtonGroup>
</span> </span>
</span>
<span className={styles.buttons}> <span className={styles.buttons}>
<ButtonGroup vertical={isMobile || !!post.expiresAt}> <ButtonGroup vertical={isMobile}>
<Button auto onClick={download} icon={<Archive />}> <Button auto onClick={download} icon={<Archive />} style={{ textTransform: 'none' }}>
Download as ZIP archive Download as ZIP Archive
</Button> </Button>
<FileDropdown files={post.files || []} /> <Button
auto
icon={<Edit />}
onClick={editACopy}
style={{ textTransform: 'none' }}>
Edit a Copy
</Button>
{console.log(post)}
{post.parent && <Button
auto
icon={<Parent />}
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))}
>
View Parent
</Button>}
<FileDropdown isMobile={isMobile} files={post.files || []} />
</ButtonGroup> </ButtonGroup>
</span> </span>
</div> </div>

View file

@ -1,51 +1,51 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header .title { .header .title {
display: flex; display: flex;
text-align: center; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--gap);
}
.header .title .badges {
display: flex;
gap: var(--gap-half);
} }
.header .title h3 { .header .title h3 {
margin: 0; margin: 0;
padding: 0; padding: 0;
} display: inline-block;
.header .title .badges > * {
margin-left: var(--gap);
} }
.header .buttons { .header .buttons {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
align-items: flex-end;
} }
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
.header { .header {
flex-direction: column; flex-direction: column;
gap: var(--gap);
} }
} }
@media screen and (max-width: 700px) { @media screen and (max-width: 700px) {
.header .title { .header .title {
flex-direction: column; flex-direction: column;
padding-bottom: var(--gap-double); gap: var(--gap-half);
}
.header .title .badges > * {
margin-left: 0;
} }
.header .title .badges { .header .title .badges {
flex-direction: column;
align-items: center;
}
.header .title .badges > * {
width: min-content;
}
.header .buttons {
display: flex; display: flex;
flex-direction: row; justify-content: center;
justify-content: space-between;
width: 100%;
} }
} }

View file

@ -22,7 +22,7 @@ type Props = {
const DownloadButton = ({ rawLink }: { rawLink?: string }) => { const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
return (<div className={styles.actionWrapper}> return (<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}> <ButtonGroup className={styles.actions}>
<Tooltip text="Download"> <Tooltip hideArrow text="Download">
<a href={`${rawLink}?download=true`} target="_blank" rel="noopener noreferrer"> <a href={`${rawLink}?download=true`} target="_blank" rel="noopener noreferrer">
<Button <Button
scale={2 / 3} px={0.6} scale={2 / 3} px={0.6}
@ -32,7 +32,7 @@ const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
/> />
</a> </a>
</Tooltip> </Tooltip>
<Tooltip text="Open raw in new tab"> <Tooltip hideArrow text="Open raw in new tab">
<a href={rawLink} target="_blank" rel="noopener noreferrer"> <a href={rawLink} target="_blank" rel="noopener noreferrer">
<Button <Button
scale={2 / 3} px={0.6} scale={2 / 3} px={0.6}

View file

@ -24,6 +24,7 @@ export type Post = {
files?: Files files?: Files
createdAt: string createdAt: string
users?: User[] users?: User[]
parent?: Pick<Post, "id" | "title" | "visibility" | "createdAt">
expiresAt: Date | string | null expiresAt: Date | string | null
} }

View file

@ -0,0 +1,81 @@
import styles from '@styles/Home.module.css'
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'
import { GetServerSideProps } from 'next'
import { Post } from '@lib/types'
import cookie from 'cookie'
const NewFromExisting = ({
post,
parentId
}: {
post: Post,
parentId: string
}) => {
console.log(parentId, post)
return (
<Page className={styles.wrapper}>
<PageSeo title="Create a new Drift" />
<Head>
{/* TODO: solve this. */}
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/css/react-datepicker.css" />
</Head>
<Page.Header>
<Header />
</Page.Header>
<Page.Content className={styles.main}>
<NewPost initialPost={post} newPostParent={parentId} />
</Page.Content>
</Page >
)
}
export const getServerSideProps: GetServerSideProps = async ({ req, params }) => {
const id = params?.id
const redirect = {
redirect: {
destination: '/new',
permanent: false,
}
}
if (!id) {
return redirect
}
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`]
const post = await fetch(`${process.env.API_URL}/posts/${id}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${driftToken}`,
"x-secret-key": process.env.SECRET_KEY || ""
}
})
if (!post.ok) {
return redirect
}
const data = await post.json()
if (!data) {
return redirect
}
return {
props: {
post: data,
parentId: id
}
}
}
export default NewFromExisting

View file

@ -8,7 +8,7 @@ import Head from 'next/head'
const New = () => { const New = () => {
return ( return (
<Page className={styles.wrapper}> <Page className={styles.wrapper}>
<PageSeo title="Drift - New" /> <PageSeo title="Create a new Drift" />
<Head> <Head>
{/* TODO: solve this. */} {/* TODO: solve this. */}
{/* eslint-disable-next-line @next/next/no-css-tags */} {/* eslint-disable-next-line @next/next/no-css-tags */}

View file

@ -1 +1,2 @@
require("dotenv").config()
require("./src/database").umzug.runAsCLI() require("./src/database").umzug.runAsCLI()

View file

@ -15,14 +15,16 @@ const config = (): Config => {
return true return true
} else if (str === "false") { } else if (str === "false") {
return false return false
} else { } else if (str) {
throw new Error(`Invalid boolean value: ${str}`) throw new Error(`Invalid boolean value: ${str}`)
} else {
return false
} }
} }
const throwIfUndefined = (str: string | undefined): string => { const throwIfUndefined = (str: string | undefined, name: string): string => {
if (str === undefined) { if (str === undefined) {
throw new Error(`Missing environment variable: ${str}`) throw new Error(`Missing environment variable: ${name}`)
} }
return str return str
} }
@ -47,7 +49,7 @@ const config = (): Config => {
is_production: process.env.NODE_ENV === "production", is_production: process.env.NODE_ENV === "production",
memory_db: stringToBoolean(process.env.MEMORY_DB), memory_db: stringToBoolean(process.env.MEMORY_DB),
enable_admin: stringToBoolean(process.env.ENABLE_ADMIN), enable_admin: stringToBoolean(process.env.ENABLE_ADMIN),
secret_key: throwIfUndefined(process.env.SECRET_KEY), secret_key: throwIfUndefined(process.env.SECRET_KEY, "SECRET_KEY"),
registration_password: process.env.REGISTRATION_PASSWORD || "" registration_password: process.env.REGISTRATION_PASSWORD || ""
} }
return config return config

View file

@ -4,6 +4,7 @@ import {
CreatedAt, CreatedAt,
DataType, DataType,
HasMany, HasMany,
HasOne,
IsUUID, IsUUID,
Model, Model,
PrimaryKey, PrimaryKey,
@ -80,5 +81,8 @@ export class Post extends Model {
@Column @Column
expiresAt?: Date expiresAt?: Date
@HasOne(() => Post, { foreignKey: "parentId", constraints: false })
parent?: Post
// TODO: deletedBy // TODO: deletedBy
} }

View file

@ -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", "parentId", {
type: DataTypes.STRING,
allowNull: true
})
export const down: Migration = async ({ context: queryInterface }) =>
await queryInterface.removeColumn("posts", "parentId")

View file

@ -38,7 +38,8 @@ posts.post(
userId: Joi.string().required(), userId: Joi.string().required(),
password: Joi.string().optional(), password: Joi.string().optional(),
// expiresAt, allow to be null // expiresAt, allow to be null
expiresAt: Joi.date().optional().allow(null, "") expiresAt: Joi.date().optional().allow(null, ""),
parentId: Joi.string().optional().allow(null, "")
} }
}), }),
async (req, res, next) => { async (req, res, next) => {
@ -98,6 +99,22 @@ posts.post(
await newPost.save() await newPost.save()
}) })
) )
if (req.body.parentId) {
// const parentPost = await Post.findOne({
// where: { id: req.body.parentId }
// })
// if (parentPost) {
// await parentPost.$add("children", newPost.id)
// await parentPost.save()
// }
const parentPost = await Post.findByPk(req.body.parentId)
if (parentPost) {
newPost.$set("parent", req.body.parentId)
await newPost.save()
} else {
throw new Error("Parent post not found")
}
}
res.json(newPost) res.json(newPost)
} catch (e) { } catch (e) {
@ -135,6 +152,11 @@ posts.get("/mine", jwt, async (req: UserJwtRequest, res, next) => {
model: File, model: File,
as: "files", as: "files",
attributes: ["id", "title", "createdAt"] attributes: ["id", "title", "createdAt"]
},
{
model: Post,
as: "parent",
attributes: ["id", "title", "visibility"]
} }
], ],
attributes: ["id", "title", "visibility", "createdAt", "expiresAt"] attributes: ["id", "title", "visibility", "createdAt", "expiresAt"]
@ -237,6 +259,11 @@ posts.get(
model: User, model: User,
as: "users", as: "users",
attributes: ["id", "username"] attributes: ["id", "username"]
},
{
model: Post,
as: "parent",
attributes: ["id", "title", "visibility", "createdAt"]
} }
], ],
attributes: [ attributes: [