Merge pull request #65 from MaxLeiter/dupePosts

client/server: add the ability to copy a post, view a posts parent
This commit is contained in:
Max Leiter 2022-04-01 22:58:49 -07:00 committed by GitHub
commit 763cb1dadc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 333 additions and 110 deletions

View file

@ -16,7 +16,7 @@ const CreatedAgoBadge = ({ createdAt }: {
}, [createdDate])
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

View file

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

View file

@ -13,9 +13,11 @@ type Item = File & {
}
const FileDropdown = ({
files
files,
isMobile
}: {
files: File[]
files: File[],
isMobile: boolean
}) => {
const [expanded, setExpanded] = useState(false)
const [items, setItems] = useState<Item[]>([])
@ -66,10 +68,11 @@ const FileDropdown = ({
// a list of files with an icon and a title
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'}
</Button>
<Popover
style={{ transform: isMobile ? "translateX(110px)" : "translateX(-75px)" }}
onVisibleChange={changeHandler}
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 { 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 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 type { Post as PostType, PostVisibility, Document as DocumentType } from '@lib/types';
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 Post = ({
initialPost,
newPostParent
}: {
initialPost?: PostType,
newPostParent?: string
}) => {
const { setToast } = useToasts()
const router = useRouter();
const [title, setTitle] = useState<string>()
const [expiresAt, setExpiresAt] = useState<Date | null>(null)
const [docs, setDocs] = useState<DocumentType[]>([{
const emptyDoc = useMemo(() => [{
title: '',
content: '',
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 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, {
method: "POST",
headers: {
@ -106,9 +137,10 @@ const Post = () => {
visibility,
password,
userId: Cookies.get('drift-userid') || '',
expiresAt
expiresAt,
parentId: newPostParent
})
}, [docs, expiresAt, sendRequest, setToast, title])
}, [docs, expiresAt, newPostParent, sendRequest, setToast, title])
const onClosePasswordModal = () => {
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 { useEffect, useMemo, useState } from "react"
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"
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
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}>
<Card style={{ overflowY: 'scroll' }}>
<Card.Body>
<Text h3 style={{
}}>
<Text h3 className={styles.title}>
<NextLink passHref={true} href={getPostPath(post.visibility, post.id)}>
<Link color marginRight={'var(--gap)'}>
{post.title}
</Link>
</NextLink>
{isOwner && <span style={{ float: 'right' }}>
<Button iconRight={<Trash />} onClick={deletePost} auto />
{isOwner && <span className={styles.buttons}>
{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>}
</Text>
<div style={{ display: 'inline-flex' }}>
<span>
<VisibilityBadge visibility={post.visibility} />
</span>
<span style={{ marginLeft: 'var(--gap)' }}>
<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>
</span>
<span style={{ marginLeft: 'var(--gap)' }}>
<ExpirationBadge postExpirationDate={post.expiresAt} />
</span>
<div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} />
<CreatedAgoBadge createdAt={post.createdAt} />
<Badge type="secondary">{post.files?.length === 1 ? "1 file" : `${post.files?.length || 0} files`}</Badge>
<ExpirationBadge postExpirationDate={post.expiresAt} />
</div>
</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 { useEffect, useState } from "react"
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 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"
import getPostPath from "@lib/get-post-path"
type Props = {
post: Post
@ -27,6 +30,9 @@ const PostPage = ({ post }: Props) => {
const [isExpired, setIsExpired] = useState(post.expiresAt ? new Date(post.expiresAt) < new Date() : null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (isExpired) {
router.push("/expired")
}
const isOwner = post.users ? post.users[0].id === Cookies.get("drift-userid") : false
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
@ -46,7 +52,7 @@ const PostPage = ({ post }: Props) => {
return () => {
if (interval) clearInterval(interval)
}
}, [post.expiresAt, post.users, router])
}, [isExpired, post.expiresAt, post.users, router])
const download = async () => {
@ -66,6 +72,10 @@ const PostPage = ({ post }: Props) => {
link.remove()
}
const editACopy = () => {
router.push(`/new/from/${post.id}`)
}
if (isLoading) {
return <></>
}
@ -85,24 +95,34 @@ const PostPage = ({ post }: Props) => {
<div className={styles.header}>
<span className={styles.title}>
<Text h3>{post.title}</Text>
<ButtonGroup
vertical={isMobile}
style={{
border: "none",
gap: 'var(--gap-half)',
marginLeft: isMobile ? "0" : "var(--gap-half)",
}}>
<span className={styles.badges}>
<VisibilityBadge visibility={post.visibility} />
<CreatedAgoBadge createdAt={post.createdAt} />
<ExpirationBadge postExpirationDate={post.expiresAt} />
</ButtonGroup>
</span>
</span>
<span className={styles.buttons}>
<ButtonGroup vertical={isMobile || !!post.expiresAt}>
<Button auto onClick={download} icon={<Archive />}>
Download as ZIP archive
<ButtonGroup vertical={isMobile}>
<Button auto onClick={download} icon={<Archive />} style={{ textTransform: 'none' }}>
Download as ZIP Archive
</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>
</span>
</div>

View file

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

View file

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

View file

@ -24,6 +24,7 @@ export type Post = {
files?: Files
createdAt: string
users?: User[]
parent?: Pick<Post, "id" | "title" | "visibility" | "createdAt">
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 = () => {
return (
<Page className={styles.wrapper}>
<PageSeo title="Drift - New" />
<PageSeo title="Create a new Drift" />
<Head>
{/* TODO: solve this. */}
{/* eslint-disable-next-line @next/next/no-css-tags */}

View file

@ -1,37 +1,37 @@
{
"compilerOptions": {
"plugins": [{ "name": "typescript-plugin-css-modules" }],
"target": "es2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": {
"@components/*": ["components/*"],
"@lib/*": ["lib/*"],
"@styles/*": ["styles/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"compilerOptions": {
"plugins": [{ "name": "typescript-plugin-css-modules" }],
"target": "es2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": {
"@components/*": ["components/*"],
"@lib/*": ["lib/*"],
"@styles/*": ["styles/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View file

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

View file

@ -15,14 +15,16 @@ const config = (): Config => {
return true
} else if (str === "false") {
return false
} else {
} else if (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) {
throw new Error(`Missing environment variable: ${str}`)
throw new Error(`Missing environment variable: ${name}`)
}
return str
}
@ -47,7 +49,7 @@ const config = (): Config => {
is_production: process.env.NODE_ENV === "production",
memory_db: stringToBoolean(process.env.MEMORY_DB),
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 || ""
}
return config

View file

@ -4,6 +4,7 @@ import {
CreatedAt,
DataType,
HasMany,
HasOne,
IsUUID,
Model,
PrimaryKey,
@ -80,5 +81,8 @@ export class Post extends Model {
@Column
expiresAt?: Date
@HasOne(() => Post, { foreignKey: "parentId", constraints: false })
parent?: Post
// 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(),
password: Joi.string().optional(),
// 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) => {
@ -98,6 +99,22 @@ posts.post(
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)
} catch (e) {
@ -135,6 +152,11 @@ posts.get("/mine", jwt, async (req: UserJwtRequest, res, next) => {
model: File,
as: "files",
attributes: ["id", "title", "createdAt"]
},
{
model: Post,
as: "parent",
attributes: ["id", "title", "visibility"]
}
],
attributes: ["id", "title", "visibility", "createdAt", "expiresAt"]
@ -237,6 +259,11 @@ posts.get(
model: User,
as: "users",
attributes: ["id", "username"]
},
{
model: Post,
as: "parent",
attributes: ["id", "title", "visibility", "createdAt"]
}
],
attributes: [