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:
commit
763cb1dadc
18 changed files with 333 additions and 110 deletions
|
@ -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
|
||||
|
|
|
@ -51,6 +51,7 @@ const ExpirationBadge = ({
|
|||
<Tooltip
|
||||
text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}>
|
||||
{isExpired ? "Expired" : `Expires ${timeUntilString}`}
|
||||
hideArrow
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
)
|
||||
|
|
|
@ -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} />
|
||||
</>
|
||||
|
|
|
@ -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)
|
||||
|
|
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 { 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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
1
client/lib/types.d.ts
vendored
1
client/lib/types.d.ts
vendored
|
@ -24,6 +24,7 @@ export type Post = {
|
|||
files?: Files
|
||||
createdAt: string
|
||||
users?: User[]
|
||||
parent?: Pick<Post, "id" | "title" | "visibility" | "createdAt">
|
||||
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 = () => {
|
||||
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 */}
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
require("dotenv").config()
|
||||
require("./src/database").umzug.runAsCLI()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
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(),
|
||||
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: [
|
||||
|
|
Loading…
Reference in a new issue