client/server: add the ability to copy a post, view a posts parent
This commit is contained in:
parent
ce01eba9c0
commit
b8cdc2cf72
18 changed files with 333 additions and 110 deletions
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
30
client/components/post-list/list-item.module.css
Normal file
30
client/components/post-list/list-item.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
1
client/lib/types.d.ts
vendored
1
client/lib/types.d.ts
vendored
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
81
client/pages/new/from/[id].tsx
Normal file
81
client/pages/new/from/[id].tsx
Normal 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
|
|
@ -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 */}
|
|
@ -1 +1,2 @@
|
||||||
|
require("dotenv").config()
|
||||||
require("./src/database").umzug.runAsCLI()
|
require("./src/database").umzug.runAsCLI()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
12
server/src/migrations/06_duplicate_post.ts
Normal file
12
server/src/migrations/06_duplicate_post.ts
Normal 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")
|
|
@ -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: [
|
||||||
|
|
Loading…
Reference in a new issue