use custom badge component, add post deletion
This commit is contained in:
parent
5f4749ebb3
commit
97cff7eb53
35 changed files with 418 additions and 230 deletions
|
@ -1,3 +1,8 @@
|
||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals"
|
"extends": "next/core-web-vitals",
|
||||||
|
"settings": {
|
||||||
|
"next": {
|
||||||
|
"rootDir": "client/"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { memo, useEffect, useState } from "react"
|
||||||
import styles from "./preview.module.css"
|
import styles from "./preview.module.css"
|
||||||
import "@styles/markdown.css"
|
import "@styles/markdown.css"
|
||||||
import "@styles/syntax.css"
|
import "@styles/syntax.css"
|
||||||
|
import Skeleton from "@components/skeleton"
|
||||||
|
import { Spinner } from "@geist-ui/core/dist"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
height?: number | string
|
height?: number | string
|
||||||
|
@ -47,11 +49,11 @@ const MarkdownPreview = ({
|
||||||
}
|
}
|
||||||
fetchPost()
|
fetchPost()
|
||||||
}, [initial, fileId, title])
|
}, [initial, fileId, title])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div>Loading...</div>
|
<><Spinner /></>
|
||||||
) : (
|
) : (
|
||||||
<StaticPreview content={content} height={height} />
|
<StaticPreview content={content} height={height} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -99,7 +99,7 @@ function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
{!isDragActive && (
|
{!isDragActive && (
|
||||||
<Text p>Drag some files here, or {verb} to select files</Text>
|
<p style={{color: "var(--gray)"}}>Drag some files here, or {verb} to select files</p>
|
||||||
)}
|
)}
|
||||||
{isDragActive && <Text p>Release to drop the files here</Text>}
|
{isDragActive && <Text p>Release to drop the files here</Text>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -88,7 +88,6 @@ const Document = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Spacer height={1} />
|
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.fileNameContainer}>
|
<div className={styles.fileNameContainer}>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
@ -284,31 +284,24 @@ const Post = ({
|
||||||
Add a File
|
Add a File
|
||||||
</Button>
|
</Button>
|
||||||
<div className={styles.rightButtons}>
|
<div className={styles.rightButtons}>
|
||||||
{
|
<DatePicker
|
||||||
<DatePicker
|
onChange={onChangeExpiration}
|
||||||
onChange={onChangeExpiration}
|
customInput={
|
||||||
customInput={
|
<Input label="Expires at" clearable width="100%" height="40px" />
|
||||||
<Input
|
}
|
||||||
label="Expires at"
|
placeholderText="Won't expire"
|
||||||
clearable
|
selected={expiresAt}
|
||||||
width="100%"
|
showTimeInput={true}
|
||||||
height="40px"
|
// @ts-ignore
|
||||||
/>
|
customTimeInput={<CustomTimeInput />}
|
||||||
}
|
timeInputLabel="Time:"
|
||||||
placeholderText="Won't expire"
|
dateFormat="MM/dd/yyyy h:mm aa"
|
||||||
selected={expiresAt}
|
className={styles.datePicker}
|
||||||
showTimeInput={true}
|
clearButtonTitle={"Clear"}
|
||||||
// @ts-ignore
|
// TODO: investigate why this causes margin shift if true
|
||||||
customTimeInput={<CustomTimeInput />}
|
enableTabLoop={false}
|
||||||
timeInputLabel="Time:"
|
minDate={new Date()}
|
||||||
dateFormat="MM/dd/yyyy h:mm aa"
|
/>
|
||||||
className={styles.datePicker}
|
|
||||||
clearButtonTitle={"Clear"}
|
|
||||||
// TODO: investigate why this causes margin shift if true
|
|
||||||
enableTabLoop={false}
|
|
||||||
minDate={new Date()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
<ButtonDropdown loading={isSubmitting} type="success">
|
<ButtonDropdown loading={isSubmitting} type="success">
|
||||||
<ButtonDropdown.Item main onClick={() => onSubmit("unlisted")}>
|
<ButtonDropdown.Item main onClick={() => onSubmit("unlisted")}>
|
||||||
Create Unlisted
|
Create Unlisted
|
||||||
|
|
|
@ -19,13 +19,13 @@ import VisibilityControl from "@components/badges/visibility-control"
|
||||||
import { File, PostWithFiles } from "@lib/server/prisma"
|
import { File, PostWithFiles } from "@lib/server/prisma"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
post: PostWithFiles
|
post: string | PostWithFiles
|
||||||
isProtected?: boolean
|
isProtected?: boolean
|
||||||
isAuthor?: boolean
|
isAuthor?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
|
const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
|
||||||
const [post, setPost] = useState<PostWithFiles>(initialPost)
|
const [post, setPost] = useState<PostWithFiles>(typeof initialPost === "string" ? JSON.parse(initialPost) : initialPost)
|
||||||
const [visibility, setVisibility] = useState<string>(post.visibility)
|
const [visibility, setVisibility] = useState<string>(post.visibility)
|
||||||
const [isExpired, setIsExpired] = useState(
|
const [isExpired, setIsExpired] = useState(
|
||||||
post.expiresAt ? new Date(post.expiresAt) < new Date() : null
|
post.expiresAt ? new Date(post.expiresAt) < new Date() : null
|
||||||
|
@ -50,7 +50,7 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
|
||||||
if (post.expiresAt) {
|
if (post.expiresAt) {
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
|
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
|
||||||
setIsExpired(expirationDate < new Date())
|
if (expirationDate < new Date()) setIsExpired(true)
|
||||||
}, 4000)
|
}, 4000)
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -128,7 +128,7 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.title}>
|
<span className={styles.title}>
|
||||||
<Text h3>{post.title}</Text>
|
<h3>{post.title}</h3>
|
||||||
<span className={styles.badges}>
|
<span className={styles.badges}>
|
||||||
<VisibilityBadge visibility={visibility} />
|
<VisibilityBadge visibility={visibility} />
|
||||||
<CreatedAgoBadge createdAt={post.createdAt} />
|
<CreatedAgoBadge createdAt={post.createdAt} />
|
||||||
|
@ -138,7 +138,7 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
|
||||||
</div>
|
</div>
|
||||||
{post.description && (
|
{post.description && (
|
||||||
<div>
|
<div>
|
||||||
<Text p>{post.description}</Text>
|
<p>{post.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
|
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { memo, useRef, useState } from "react"
|
import { memo, useRef } from "react"
|
||||||
import styles from "./document.module.css"
|
import styles from "./document.module.css"
|
||||||
import Download from "@geist-ui/icons/download"
|
import Download from "@geist-ui/icons/download"
|
||||||
import ExternalLink from "@geist-ui/icons/externalLink"
|
import ExternalLink from "@geist-ui/icons/externalLink"
|
||||||
import Skeleton from "react-loading-skeleton"
|
import Skeleton from "@components/skeleton"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -71,15 +71,12 @@ const Document = ({
|
||||||
id
|
id
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const [tab, setTab] = useState(initialTab)
|
|
||||||
// const height = editable ? "500px" : '100%'
|
|
||||||
const height = "100%"
|
const height = "100%"
|
||||||
|
|
||||||
const handleTabChange = (newTab: string) => {
|
const handleTabChange = (newTab: string) => {
|
||||||
if (newTab === "edit") {
|
if (newTab === "edit") {
|
||||||
codeEditorRef.current?.focus()
|
codeEditorRef.current?.focus()
|
||||||
}
|
}
|
||||||
setTab(newTab as "edit" | "preview")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawLink = () => {
|
const rawLink = () => {
|
||||||
|
|
|
@ -1,23 +1,25 @@
|
||||||
import type { GetServerSideProps } from "next"
|
|
||||||
|
|
||||||
import type { Post } from "@lib/types"
|
import type { Post } from "@lib/types"
|
||||||
import PostPage from "app/(posts)/post/[id]/components/post-page"
|
import PostPage from "app/(posts)/post/[id]/components/post-page"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { getAllPosts, getPostById } from "@lib/server/prisma"
|
import { getAllPosts, getPostById } from "@lib/server/prisma"
|
||||||
import { getCurrentUser, getSession } from "@lib/server/session"
|
import { getCurrentUser } from "@lib/server/session"
|
||||||
|
|
||||||
export type PostProps = {
|
export type PostProps = {
|
||||||
post: Post
|
post: Post
|
||||||
isProtected?: boolean
|
isProtected?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
// export async function generateStaticParams() {
|
||||||
const posts = await getAllPosts()
|
// const posts = await getAllPosts({
|
||||||
|
// where: {
|
||||||
|
// visibility: "public"
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
|
||||||
return posts.map((post) => ({
|
// return posts.map((post) => ({
|
||||||
id: post.id
|
// id: post.id
|
||||||
}))
|
// }))
|
||||||
}
|
// }
|
||||||
|
|
||||||
const getPost = async (id: string) => {
|
const getPost = async (id: string) => {
|
||||||
const post = await getPostById(id, true)
|
const post = await getPostById(id, true)
|
||||||
|
@ -30,7 +32,7 @@ const getPost = async (id: string) => {
|
||||||
const isAuthor = user?.id === post?.authorId
|
const isAuthor = user?.id === post?.authorId
|
||||||
|
|
||||||
if (post.visibility === "public") {
|
if (post.visibility === "public") {
|
||||||
return { post, isAuthor, signedIn: Boolean(user) }
|
return { post, isAuthor }
|
||||||
}
|
}
|
||||||
|
|
||||||
// must be authed to see unlisted/private
|
// must be authed to see unlisted/private
|
||||||
|
@ -49,12 +51,11 @@ const getPost = async (id: string) => {
|
||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
isProtected: true,
|
isProtected: true,
|
||||||
isAuthor,
|
isAuthor
|
||||||
signedIn: Boolean(user)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { post, isAuthor, signedIn: Boolean(user) }
|
return { post, isAuthor }
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostView = async ({
|
const PostView = async ({
|
||||||
|
@ -64,8 +65,10 @@ const PostView = async ({
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
}) => {
|
}) => {
|
||||||
const { post, isProtected, isAuthor, signedIn } = await getPost(params.id)
|
const { post, isProtected, isAuthor } = await getPost(params.id)
|
||||||
return <PostPage isAuthor={isAuthor} isProtected={isProtected} post={post} />
|
// TODO: serialize dates in prisma middleware instead of passing as JSON
|
||||||
|
const stringifiedPost = JSON.stringify(post);
|
||||||
|
return <PostPage isAuthor={isAuthor} isProtected={isProtected} post={stringifiedPost} />
|
||||||
}
|
}
|
||||||
|
|
||||||
// export const getServerSideProps: GetServerSideProps = async ({
|
// export const getServerSideProps: GetServerSideProps = async ({
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import { isUserAdmin } from "@lib/server/prisma"
|
|
||||||
import { getCurrentUser } from "@lib/server/session"
|
import { getCurrentUser } from "@lib/server/session"
|
||||||
import Admin from "./components/admin"
|
import Admin from "./components/admin"
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
const AdminPage = async () => {
|
const AdminPage = async () => {
|
||||||
|
|
39
client/app/components/badges/badge.module.css
Normal file
39
client/app/components/badges/badge.module.css
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
.container {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .25em .5em;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--light-gray);
|
||||||
|
font-size: .85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeText {
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background-color: var(--fg);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary::selection {
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background-color: var(--light-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
background-color: var(--warning);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: red;
|
||||||
|
}
|
17
client/app/components/badges/badge.tsx
Normal file
17
client/app/components/badges/badge.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import styles from "./badge.module.css"
|
||||||
|
type BadgeProps = {
|
||||||
|
type: "primary" | "secondary" | "error" | "warning"
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Badge = ({ type, children }: BadgeProps) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={`${styles.badge} ${styles[type]}`}>
|
||||||
|
<span className={styles.badgeText}>{children}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Badge
|
|
@ -1,7 +1,7 @@
|
||||||
import Tooltip from "@components/tooltip"
|
import Tooltip from "@components/tooltip"
|
||||||
import { Badge } from "@geist-ui/core/dist"
|
|
||||||
import { timeAgo } from "@lib/time-ago"
|
import { timeAgo } from "@lib/time-ago"
|
||||||
import { useMemo, useState, useEffect } from "react"
|
import { useMemo, useState, useEffect } from "react"
|
||||||
|
import Badge from "../badge"
|
||||||
|
|
||||||
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
|
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
|
||||||
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
|
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
|
||||||
|
@ -19,7 +19,7 @@ const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
|
||||||
<Badge type="secondary">
|
<Badge type="secondary">
|
||||||
{" "}
|
{" "}
|
||||||
<Tooltip content={formattedTime}>
|
<Tooltip content={formattedTime}>
|
||||||
<>Created {time}</>
|
<>{time}</>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import Tooltip from "@components/tooltip"
|
import Tooltip from "@components/tooltip"
|
||||||
import { Badge } from "@geist-ui/core/dist"
|
|
||||||
import { timeUntil } from "@lib/time-ago"
|
import { timeUntil } from "@lib/time-ago"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Badge from "../badge"
|
||||||
|
|
||||||
const ExpirationBadge = ({
|
const ExpirationBadge = ({
|
||||||
postExpirationDate
|
postExpirationDate
|
||||||
|
@ -36,7 +36,7 @@ const ExpirationBadge = ({
|
||||||
}, [expirationDate])
|
}, [expirationDate])
|
||||||
|
|
||||||
const isExpired = useMemo(() => {
|
const isExpired = useMemo(() => {
|
||||||
return timeUntilString && timeUntilString === "in 0 seconds"
|
return timeUntilString === "in 0 seconds"
|
||||||
}, [timeUntilString])
|
}, [timeUntilString])
|
||||||
|
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Badge } from "@geist-ui/core/dist"
|
|
||||||
import type { PostVisibility } from "@lib/types"
|
import type { PostVisibility } from "@lib/types"
|
||||||
|
import Badge from "../badge"
|
||||||
|
|
||||||
type CastPostVisibility = PostVisibility | string
|
type CastPostVisibility = PostVisibility | string
|
||||||
|
|
||||||
|
@ -8,18 +8,7 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const VisibilityBadge = ({ visibility }: Props) => {
|
const VisibilityBadge = ({ visibility }: Props) => {
|
||||||
const getBadgeType = () => {
|
return <Badge type={"primary"}>{visibility}</Badge>
|
||||||
switch (visibility) {
|
|
||||||
case "public":
|
|
||||||
return "success"
|
|
||||||
case "private":
|
|
||||||
return "warning"
|
|
||||||
case "unlisted":
|
|
||||||
return "default"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Badge type={getBadgeType()}>{visibility}</Badge>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default VisibilityBadge
|
export default VisibilityBadge
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import config from "@lib/config"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
type PageSeoProps = {
|
type PageSeoProps = {
|
||||||
|
@ -14,11 +15,74 @@ const PageSeo = ({
|
||||||
}: PageSeoProps) => {
|
}: PageSeoProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>Drift - {title}</title>
|
<title>Drift{title ? ` - ${title}` : ""}</title>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
{!isPrivate && <meta name="description" content={description} />}
|
{!isPrivate && <meta name="description" content={description} />}
|
||||||
{isPrivate && <meta name="robots" content="noindex" />}
|
{isPrivate && <meta name="robots" content="noindex" />}
|
||||||
|
|
||||||
|
{/* TODO: verify the correct meta tags */}
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||||
|
/>
|
||||||
|
<ThemeAndIcons />
|
||||||
|
<URLs />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PageSeo
|
export default PageSeo
|
||||||
|
|
||||||
|
const ThemeAndIcons = () => (
|
||||||
|
<>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="/assets/favicon-32x32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="/assets/favicon-16x16.png"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<link
|
||||||
|
rel="mask-icon"
|
||||||
|
href="/assets/safari-pinned-tab.svg"
|
||||||
|
color="#5bbad5"
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Drift" />
|
||||||
|
<meta name="application-name" content="Drift" />
|
||||||
|
<meta name="msapplication-TileColor" content="#da532c" />
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#ffffff"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#000"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const URLs = () => (
|
||||||
|
<>
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:url" content={config.url} />
|
||||||
|
{/* TODO: OG image */}
|
||||||
|
<meta property="twitter:image" content={`${config.url}/assets/og.png`} />
|
||||||
|
<meta property="twitter:site" content="@" />
|
||||||
|
<meta property="twitter:creator" content="@drift" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content={config.url} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
|
@ -8,19 +8,25 @@ import ListItem from "./list-item"
|
||||||
import { ChangeEvent, useCallback, useEffect, useState } from "react"
|
import { ChangeEvent, useCallback, useEffect, useState } from "react"
|
||||||
import useDebounce from "@lib/hooks/use-debounce"
|
import useDebounce from "@lib/hooks/use-debounce"
|
||||||
import Link from "@components/link"
|
import Link from "@components/link"
|
||||||
import { TOKEN_COOKIE_NAME } from "@lib/constants"
|
|
||||||
import type { PostWithFiles } from "@lib/server/prisma"
|
import type { PostWithFiles } from "@lib/server/prisma"
|
||||||
import DriftTooltip from "@components/tooltip"
|
|
||||||
import { Search } from "@geist-ui/icons"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialPosts: PostWithFiles[]
|
initialPosts: string | PostWithFiles[]
|
||||||
morePosts: boolean
|
morePosts: boolean
|
||||||
|
userId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostList = ({ morePosts, initialPosts }: Props) => {
|
const PostList = ({
|
||||||
|
morePosts,
|
||||||
|
initialPosts: initialPostsMaybeJSON,
|
||||||
|
userId
|
||||||
|
}: Props) => {
|
||||||
|
const initialPosts =
|
||||||
|
typeof initialPostsMaybeJSON === "string"
|
||||||
|
? JSON.parse(initialPostsMaybeJSON)
|
||||||
|
: initialPostsMaybeJSON
|
||||||
const [search, setSearchValue] = useState("")
|
const [search, setSearchValue] = useState("")
|
||||||
const [posts, setPosts] = useState(initialPosts)
|
const [posts, setPosts] = useState<PostWithFiles[]>(initialPosts)
|
||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
const [hasMorePosts, setHasMorePosts] = useState(morePosts)
|
const [hasMorePosts, setHasMorePosts] = useState(morePosts)
|
||||||
|
|
||||||
|
@ -51,54 +57,39 @@ const PostList = ({ morePosts, initialPosts }: Props) => {
|
||||||
// update posts on search
|
// update posts on search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debouncedSearchValue) {
|
if (debouncedSearchValue) {
|
||||||
// fetch results from /server-api/posts/search
|
setSearching(true)
|
||||||
const fetchResults = async () => {
|
async function fetchPosts() {
|
||||||
setSearching(true)
|
|
||||||
//encode search
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/server-api/posts/search?q=${encodeURIComponent(
|
`/api/post/search?q=${encodeURIComponent(
|
||||||
debouncedSearchValue
|
debouncedSearchValue
|
||||||
)}`,
|
)}&userId=${userId}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
// "tok": process.env.SECRET_KEY || ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const data = await res.json()
|
const json = await res.json()
|
||||||
setPosts(data)
|
setPosts(json.posts)
|
||||||
setSearching(false)
|
setSearching(false)
|
||||||
}
|
}
|
||||||
fetchResults()
|
fetchPosts()
|
||||||
} else {
|
} else {
|
||||||
setPosts(initialPosts)
|
setPosts(initialPosts)
|
||||||
}
|
}
|
||||||
}, [initialPosts, debouncedSearchValue])
|
// TODO: fix cyclical dependency issue
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [debouncedSearchValue, userId])
|
||||||
|
|
||||||
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchValue(e.target.value)
|
setSearchValue(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// const debouncedSearchHandler = useMemo(
|
|
||||||
// () => debounce(handleSearchChange, 300),
|
|
||||||
// []
|
|
||||||
// )
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// return () => {
|
|
||||||
// debouncedSearchHandler.cancel()
|
|
||||||
// }
|
|
||||||
// }, [debouncedSearchHandler])
|
|
||||||
|
|
||||||
const deletePost = useCallback(
|
const deletePost = useCallback(
|
||||||
(postId: string) => async () => {
|
(postId: string) => async () => {
|
||||||
const res = await fetch(`/server-api/posts/${postId}`, {
|
const res = await fetch(`/api/post/${postId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
@ -116,14 +107,13 @@ const PostList = ({ morePosts, initialPosts }: Props) => {
|
||||||
<div className={styles.searchContainer}>
|
<div className={styles.searchContainer}>
|
||||||
<Input
|
<Input
|
||||||
scale={3 / 2}
|
scale={3 / 2}
|
||||||
clearable
|
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
disabled={Boolean(!posts?.length)}
|
disabled={Boolean(!posts?.length)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!posts && <Text type="error">Failed to load.</Text>}
|
{!posts && <Text type="error">Failed to load.</Text>}
|
||||||
{!posts.length && searching && (
|
{!posts?.length && searching && (
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<ListItemSkeleton />
|
<ListItemSkeleton />
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import Skeleton from "react-loading-skeleton"
|
import Skeleton from "@components/skeleton"
|
||||||
import { Card, Divider, Grid, Spacer } from "@geist-ui/core/dist"
|
import { Card, Divider, Grid, Spacer } from "@geist-ui/core/dist"
|
||||||
|
|
||||||
const ListItemSkeleton = () => (
|
const ListItemSkeleton = () => (
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
margin: var(--gap-quarter) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 700px) {
|
@media screen and (max-width: 700px) {
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
import VisibilityBadge from "../badges/visibility-badge"
|
import VisibilityBadge from "../badges/visibility-badge"
|
||||||
import {
|
import { Text, Card, Divider, Button } from "@geist-ui/core/dist"
|
||||||
Text,
|
|
||||||
Card,
|
|
||||||
Divider,
|
|
||||||
Badge,
|
|
||||||
Button
|
|
||||||
} from "@geist-ui/core/dist"
|
|
||||||
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 ExpirationBadge from "@components/badges/expiration-badge"
|
import ExpirationBadge from "@components/badges/expiration-badge"
|
||||||
|
@ -19,6 +13,7 @@ import type { PostWithFiles } from "@lib/server/prisma"
|
||||||
import type { PostVisibility } from "@lib/types"
|
import type { PostVisibility } from "@lib/types"
|
||||||
import type { File } from "@lib/server/prisma"
|
import type { File } from "@lib/server/prisma"
|
||||||
import Tooltip from "@components/tooltip"
|
import Tooltip from "@components/tooltip"
|
||||||
|
import Badge from "@components/badges/badge"
|
||||||
|
|
||||||
// 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 = ({
|
const ListItem = ({
|
||||||
|
@ -45,14 +40,16 @@ const ListItem = ({
|
||||||
<li key={post.id}>
|
<li key={post.id}>
|
||||||
<Card style={{ overflowY: "scroll" }}>
|
<Card style={{ overflowY: "scroll" }}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<Text h3 className={styles.title}>
|
<div className={styles.title}>
|
||||||
<Link
|
<h3 style={{ display: "inline-block" }}>
|
||||||
colored
|
<Link
|
||||||
style={{ marginRight: "var(--gap)" }}
|
colored
|
||||||
href={`/post/${post.id}`}
|
style={{ marginRight: "var(--gap)" }}
|
||||||
>
|
href={`/post/${post.id}`}
|
||||||
{post.title}
|
>
|
||||||
</Link>
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<span className={styles.buttons}>
|
<span className={styles.buttons}>
|
||||||
{post.parentId && (
|
{post.parentId && (
|
||||||
|
@ -72,22 +69,20 @@ const ListItem = ({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</div>
|
||||||
|
|
||||||
{post.description && (
|
{post.description && (
|
||||||
<Text p className={styles.oneline}>
|
<p className={styles.oneline}>{post.description}</p>
|
||||||
{post.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.badges}>
|
<div className={styles.badges}>
|
||||||
<VisibilityBadge visibility={post.visibility as PostVisibility} />
|
<VisibilityBadge visibility={post.visibility as PostVisibility} />
|
||||||
<CreatedAgoBadge createdAt={post.createdAt} />
|
|
||||||
<Badge type="secondary">
|
<Badge type="secondary">
|
||||||
{post.files?.length === 1
|
{post.files?.length === 1
|
||||||
? "1 file"
|
? "1 file"
|
||||||
: `${post.files?.length || 0} files`}
|
: `${post.files?.length || 0} files`}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<CreatedAgoBadge createdAt={post.createdAt} />
|
||||||
<ExpirationBadge postExpirationDate={post.expiresAt} />
|
<ExpirationBadge postExpirationDate={post.expiresAt} />
|
||||||
</div>
|
</div>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
|
|
11
client/app/components/skeleton/index.tsx
Normal file
11
client/app/components/skeleton/index.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import styles from "./skeleton.module.css"
|
||||||
|
|
||||||
|
export default function Skeleton({
|
||||||
|
width = 100,
|
||||||
|
height = 24,
|
||||||
|
}: {
|
||||||
|
width?: number | string
|
||||||
|
height?: number | string
|
||||||
|
}) {
|
||||||
|
return <div className={styles.skeleton} style={{ width, height }} />
|
||||||
|
}
|
4
client/app/components/skeleton/skeleton.module.css
Normal file
4
client/app/components/skeleton/skeleton.module.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.skeleton {
|
||||||
|
background-color: var(--lighter-gray);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
|
@ -9,50 +9,18 @@ interface RootLayoutProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RootLayout({ children }: RootLayoutProps) {
|
export default async function RootLayout({ children }: RootLayoutProps) {
|
||||||
|
// TODO: this opts out of SSG
|
||||||
const cookiesList = cookies();
|
const cookiesList = cookies();
|
||||||
const hasNextAuth = cookiesList.get("next-auth.session-token") !== undefined;
|
const hasNextAuth = cookiesList.get("next-auth.session-token") !== undefined;
|
||||||
return (
|
return (
|
||||||
<ServerThemeProvider
|
<ServerThemeProvider
|
||||||
cookieName="drift-theme"
|
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
attribute="data-theme"
|
attribute="data-theme"
|
||||||
enableColorScheme
|
enableColorScheme
|
||||||
>
|
>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="180x180"
|
|
||||||
href="/assets/apple-touch-icon.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="32x32"
|
|
||||||
href="/assets/favicon-32x32.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="16x16"
|
|
||||||
href="/assets/favicon-16x16.png"
|
|
||||||
/>
|
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
|
||||||
<link
|
|
||||||
rel="mask-icon"
|
|
||||||
href="/assets/safari-pinned-tab.svg"
|
|
||||||
color="#5bbad5"
|
|
||||||
/>
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Drift" />
|
|
||||||
<meta name="application-name" content="Drift" />
|
|
||||||
<meta name="msapplication-TileColor" content="#da532c" />
|
|
||||||
<meta name="theme-color" content="#ffffff" />
|
|
||||||
<title>Drift</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body className={styles.main}>
|
<body className={styles.main}>
|
||||||
<LayoutWrapper signedIn={hasNextAuth}>{children}</LayoutWrapper>
|
<LayoutWrapper signedIn={hasNextAuth}>{children}</LayoutWrapper>
|
||||||
|
|
|
@ -14,5 +14,6 @@ export default async function Mine() {
|
||||||
const posts = await getPostsByUser(userId, true)
|
const posts = await getPostsByUser(userId, true)
|
||||||
|
|
||||||
const hasMore = false
|
const hasMore = false
|
||||||
return <PostList morePosts={hasMore} initialPosts={posts} />
|
const stringifiedPosts = JSON.stringify(posts)
|
||||||
|
return <PostList userId={userId} morePosts={hasMore} initialPosts={stringifiedPosts} />
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
import Header from "@components/header"
|
import Header from "@components/header"
|
||||||
import { CssBaseline, GeistProvider, Page, Themes } from "@geist-ui/core/dist"
|
import { CssBaseline, GeistProvider, Page, Themes } from "@geist-ui/core/dist"
|
||||||
import { ThemeProvider } from "next-themes"
|
import { ThemeProvider } from "next-themes"
|
||||||
import { SkeletonTheme } from "react-loading-skeleton"
|
|
||||||
import * as RadixTooltip from "@radix-ui/react-tooltip"
|
import * as RadixTooltip from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
export function LayoutWrapper({
|
export function LayoutWrapper({
|
||||||
|
@ -56,24 +55,19 @@ export function LayoutWrapper({
|
||||||
return (
|
return (
|
||||||
<RadixTooltip.Provider delayDuration={200}>
|
<RadixTooltip.Provider delayDuration={200}>
|
||||||
<GeistProvider themes={[customTheme]} themeType={"custom"}>
|
<GeistProvider themes={[customTheme]} themeType={"custom"}>
|
||||||
<SkeletonTheme
|
<ThemeProvider
|
||||||
baseColor={skeletonBaseColor}
|
disableTransitionOnChange
|
||||||
highlightColor={skeletonHighlightColor}
|
cookieName="drift-theme"
|
||||||
|
attribute="data-theme"
|
||||||
>
|
>
|
||||||
<ThemeProvider
|
<CssBaseline />
|
||||||
disableTransitionOnChange
|
<Page width={"100%"}>
|
||||||
cookieName="drift-theme"
|
<Page.Header>
|
||||||
attribute="data-theme"
|
<Header signedIn={signedIn} />
|
||||||
>
|
</Page.Header>
|
||||||
<CssBaseline />
|
{children}
|
||||||
<Page width={"100%"}>
|
</Page>
|
||||||
<Page.Header>
|
</ThemeProvider>
|
||||||
<Header signedIn={signedIn} />
|
|
||||||
</Page.Header>
|
|
||||||
{children}
|
|
||||||
</Page>
|
|
||||||
</ThemeProvider>
|
|
||||||
</SkeletonTheme>
|
|
||||||
</GeistProvider>
|
</GeistProvider>
|
||||||
</RadixTooltip.Provider>
|
</RadixTooltip.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { useState } from "react"
|
||||||
|
|
||||||
const Profile = ({ user }: { user: User }) => {
|
const Profile = ({ user }: { user: User }) => {
|
||||||
const [name, setName] = useState<string>(user.name || "")
|
const [name, setName] = useState<string>(user.name || "")
|
||||||
const [email, setEmail] = useState<string>(user.email || "")
|
|
||||||
const [bio, setBio] = useState<string>()
|
const [bio, setBio] = useState<string>()
|
||||||
|
|
||||||
const { setToast } = useToasts()
|
const { setToast } = useToasts()
|
||||||
|
@ -16,17 +15,13 @@ const Profile = ({ user }: { user: User }) => {
|
||||||
setName(e.target.value)
|
setName(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setEmail(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBioChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleBioChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setBio(e.target.value)
|
setBio(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!name && !email && !bio) {
|
if (!name && !bio) {
|
||||||
setToast({
|
setToast({
|
||||||
text: "Please fill out at least one field",
|
text: "Please fill out at least one field",
|
||||||
type: "error"
|
type: "error"
|
||||||
|
@ -36,7 +31,6 @@ const Profile = ({ user }: { user: User }) => {
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
displayName: name,
|
displayName: name,
|
||||||
email,
|
|
||||||
bio
|
bio
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,8 +86,8 @@ const Profile = ({ user }: { user: User }) => {
|
||||||
htmlType="email"
|
htmlType="email"
|
||||||
width={"100%"}
|
width={"100%"}
|
||||||
placeholder="my@email.io"
|
placeholder="my@email.io"
|
||||||
value={email || ""}
|
value={user.email || undefined}
|
||||||
onChange={handleEmailChange}
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -111,7 +111,9 @@ input,
|
||||||
button,
|
button,
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
|
border: var(--border);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
.keyword {
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--darker-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.operator,
|
.token.operator,
|
||||||
.token.punctuation,
|
.token.punctuation,
|
||||||
.token.builtin,
|
.token.builtin,
|
||||||
|
@ -10,6 +5,7 @@
|
||||||
color: var(--token);
|
color: var(--token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token.keyword,
|
||||||
.token.string,
|
.token.string,
|
||||||
.token.number,
|
.token.number,
|
||||||
.token.boolean {
|
.token.boolean {
|
||||||
|
|
|
@ -30,21 +30,25 @@ export const config = (env: Environment): Config => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const throwIfUndefined = (str: EnvironmentValue, name: string): string => {
|
// TODO: improve `key` type
|
||||||
if (str === undefined) {
|
const throwIfUndefined = (key: keyof Environment): string => {
|
||||||
throw new Error(`Missing environment variable: ${name}`)
|
const value = env[key]
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error(`Missing environment variable: ${key}`)
|
||||||
}
|
}
|
||||||
return str
|
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIfUndefined = (
|
const defaultIfUndefined = (
|
||||||
str: EnvironmentValue,
|
str: string,
|
||||||
defaultValue: string
|
defaultValue: string
|
||||||
): string => {
|
): string => {
|
||||||
if (str === undefined) {
|
const value = env[str]
|
||||||
|
if (value === undefined) {
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
return str
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const validNodeEnvs = (str: EnvironmentValue) => {
|
const validNodeEnvs = (str: EnvironmentValue) => {
|
||||||
|
@ -61,12 +65,11 @@ export const config = (env: Environment): Config => {
|
||||||
const is_production = env.NODE_ENV === "production"
|
const is_production = env.NODE_ENV === "production"
|
||||||
|
|
||||||
const developmentDefault = (
|
const developmentDefault = (
|
||||||
str: EnvironmentValue,
|
|
||||||
name: string,
|
name: string,
|
||||||
defaultValue: string
|
defaultValue: string
|
||||||
): string => {
|
): string => {
|
||||||
if (is_production) return throwIfUndefined(str, name)
|
if (is_production) return throwIfUndefined(name)
|
||||||
return defaultIfUndefined(str, defaultValue)
|
return defaultIfUndefined(name, defaultValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
validNodeEnvs(env.NODE_ENV)
|
validNodeEnvs(env.NODE_ENV)
|
||||||
|
@ -78,11 +81,11 @@ export const config = (env: Environment): Config => {
|
||||||
is_production,
|
is_production,
|
||||||
memory_db: stringToBoolean(env.MEMORY_DB),
|
memory_db: stringToBoolean(env.MEMORY_DB),
|
||||||
enable_admin: stringToBoolean(env.ENABLE_ADMIN),
|
enable_admin: stringToBoolean(env.ENABLE_ADMIN),
|
||||||
secret_key: developmentDefault(env.SECRET_KEY, "SECRET_KEY", "secret"),
|
secret_key: developmentDefault("SECRET_KEY", "secret"),
|
||||||
registration_password: env.REGISTRATION_PASSWORD ?? "",
|
registration_password: env.REGISTRATION_PASSWORD ?? "",
|
||||||
welcome_content: env.WELCOME_CONTENT ?? "",
|
welcome_content: env.WELCOME_CONTENT ?? "",
|
||||||
welcome_title: env.WELCOME_TITLE ?? "",
|
welcome_title: env.WELCOME_TITLE ?? "",
|
||||||
url: "http://localhost:3000",
|
url: throwIfUndefined("DRIFT_URL"),
|
||||||
GITHUB_CLIENT_ID: env.GITHUB_CLIENT_ID ?? "",
|
GITHUB_CLIENT_ID: env.GITHUB_CLIENT_ID ?? "",
|
||||||
GITHUB_CLIENT_SECRET: env.GITHUB_CLIENT_SECRET ?? "",
|
GITHUB_CLIENT_SECRET: env.GITHUB_CLIENT_SECRET ?? "",
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
import config from "@lib/config"
|
import config from "@lib/config"
|
||||||
import { Post, PrismaClient, File, User } from "@prisma/client"
|
import { Post, PrismaClient, File, User, Prisma } from "@prisma/client"
|
||||||
|
|
||||||
// we want to update iff they exist the createdAt/updated/expired/deleted items
|
// we want to update iff they exist the createdAt/updated/expired/deleted items
|
||||||
// the input could be an array, in which case we'd check each item in the array
|
// the input could be an array, in which case we'd check each item in the array
|
||||||
|
@ -12,16 +12,16 @@ import { Post, PrismaClient, File, User } from "@prisma/client"
|
||||||
|
|
||||||
const updateDateForItem = (item: any) => {
|
const updateDateForItem = (item: any) => {
|
||||||
if (item.createdAt) {
|
if (item.createdAt) {
|
||||||
item.createdAt = item.createdAt.toISOString()
|
item.createdAt = item.createdAt.toString()
|
||||||
}
|
}
|
||||||
if (item.updatedAt) {
|
if (item.updatedAt) {
|
||||||
item.updatedAt = item.updatedAt.toISOString()
|
item.updatedAt = item.updatedAt.toString()
|
||||||
}
|
}
|
||||||
if (item.expiresAt) {
|
if (item.expiresAt) {
|
||||||
item.expiresAt = item.expiresAt.toISOString()
|
item.expiresAt = item.expiresAt.toString()
|
||||||
}
|
}
|
||||||
if (item.deletedAt) {
|
if (item.deletedAt) {
|
||||||
item.deletedAt = item.deletedAt.toISOString()
|
item.deletedAt = item.deletedAt.toString()
|
||||||
}
|
}
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
@ -40,6 +40,11 @@ export const prisma =
|
||||||
log: ["query"]
|
log: ["query"]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// prisma.$use(async (params, next) => {
|
||||||
|
// const result = await next(params)
|
||||||
|
// return updateDates(result)
|
||||||
|
// })
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") global.prisma = prisma
|
if (process.env.NODE_ENV !== "production") global.prisma = prisma
|
||||||
|
|
||||||
export type { User, File, Post } from "@prisma/client"
|
export type { User, File, Post } from "@prisma/client"
|
||||||
|
@ -159,13 +164,59 @@ export const getPostById = async (postId: Post["id"], withFiles = false) => {
|
||||||
return post as PostWithFiles
|
return post as PostWithFiles
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAllPosts = async (withFiles = false) => {
|
export const getAllPosts = async ({
|
||||||
|
withFiles = false,
|
||||||
|
take = 100,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
withFiles?: boolean
|
||||||
|
} & Prisma.PostFindManyArgs = {}) => {
|
||||||
const posts = await prisma.post.findMany({
|
const posts = await prisma.post.findMany({
|
||||||
include: {
|
include: {
|
||||||
files: withFiles
|
files: withFiles
|
||||||
},
|
},
|
||||||
// TODO: optimize which to grab
|
// TODO: optimize which to grab
|
||||||
take: 100
|
take,
|
||||||
|
...rest
|
||||||
|
})
|
||||||
|
|
||||||
|
return posts as PostWithFiles[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchPosts = async (
|
||||||
|
query: string,
|
||||||
|
{
|
||||||
|
withFiles = false,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
withFiles?: boolean
|
||||||
|
userId?: User["id"]
|
||||||
|
} = {}
|
||||||
|
): Promise<PostWithFiles[]> => {
|
||||||
|
const posts = await prisma.post.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
search: query
|
||||||
|
},
|
||||||
|
authorId: userId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: {
|
||||||
|
some: {
|
||||||
|
content: {
|
||||||
|
search: query
|
||||||
|
},
|
||||||
|
userId: userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
files: withFiles
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return posts as PostWithFiles[]
|
return posts as PostWithFiles[]
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { NextResponse } from "next/server"
|
||||||
|
|
||||||
export default withAuth(
|
export default withAuth(
|
||||||
async function middleware(req) {
|
async function middleware(req) {
|
||||||
console.log("middleware")
|
|
||||||
const token = await getToken({ req })
|
const token = await getToken({ req })
|
||||||
|
|
||||||
const isAuth = !!token
|
const isAuth = !!token
|
||||||
const isAuthPage =
|
const isAuthPage =
|
||||||
req.nextUrl.pathname.startsWith("/signup") ||
|
req.nextUrl.pathname.startsWith("/signup") ||
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
"client-zip": "2.2.1",
|
"client-zip": "2.2.1",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
|
"cookies-next": "^2.1.1",
|
||||||
"next": "13.0.3-canary.4",
|
"next": "13.0.3-canary.4",
|
||||||
"next-auth": "^4.16.4",
|
"next-auth": "^4.16.4",
|
||||||
"next-themes": "npm:@wits/next-themes@0.2.7",
|
"next-themes": "npm:@wits/next-themes@0.2.7",
|
||||||
|
@ -31,7 +32,6 @@
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-dropzone": "14.2.3",
|
"react-dropzone": "14.2.3",
|
||||||
"react-hot-toast": "^2.4.0",
|
"react-hot-toast": "^2.4.0",
|
||||||
"react-loading-skeleton": "3.1.0",
|
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"swr": "1.3.0",
|
"swr": "1.3.0",
|
||||||
"textarea-markdown-editor": "0.1.13"
|
"textarea-markdown-editor": "0.1.13"
|
||||||
|
|
|
@ -9,9 +9,10 @@ import * as crypto from "crypto"
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method === "GET") return handleGet(req, res)
|
if (req.method === "GET") return handleGet(req, res)
|
||||||
else if (req.method === "PUT") return handlePut(req, res)
|
else if (req.method === "PUT") return handlePut(req, res)
|
||||||
|
else if (req.method === "DELETE") return handleDelete(req, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withMethods(["GET", "PUT"], handler)
|
export default withMethods(["GET", "PUT", "DELETE"], handler)
|
||||||
|
|
||||||
async function handleGet(req: NextApiRequest, res: NextApiResponse<any>) {
|
async function handleGet(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||||
const id = parseQueryParam(req.query.id)
|
const id = parseQueryParam(req.query.id)
|
||||||
|
@ -114,3 +115,34 @@ async function handlePut(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||||
visibility: updatedPost.visibility
|
visibility: updatedPost.visibility
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete(req: NextApiRequest, res: NextApiResponse<any>) {
|
||||||
|
const id = parseQueryParam(req.query.id)
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: "Missing id" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await getPostById(id, false)
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ message: "Post not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession({ req })
|
||||||
|
|
||||||
|
const isAuthor = session?.user.id === post.authorId
|
||||||
|
const isAdmin = session?.user.role === "admin"
|
||||||
|
|
||||||
|
if (!isAuthor && !isAdmin) {
|
||||||
|
return res.status(403).json({ message: "Unauthorized" })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.post.delete({
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ message: "Post deleted" })
|
||||||
|
}
|
||||||
|
|
27
client/pages/api/post/search.ts
Normal file
27
client/pages/api/post/search.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { withMethods } from "@lib/api-middleware/with-methods"
|
||||||
|
import { parseQueryParam } from "@lib/server/parse-query-param"
|
||||||
|
import { searchPosts } from "@lib/server/prisma"
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next"
|
||||||
|
|
||||||
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
const { q, userId } = req.query
|
||||||
|
|
||||||
|
const query = parseQueryParam(q)
|
||||||
|
if (!query) {
|
||||||
|
res.status(400).json({ error: "Invalid query" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const posts = await searchPosts(query, {
|
||||||
|
userId: parseQueryParam(userId),
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).json(posts)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: "Internal server error" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withMethods(["GET"], handler)
|
|
@ -17,6 +17,7 @@ specifiers:
|
||||||
bcrypt: ^5.1.0
|
bcrypt: ^5.1.0
|
||||||
client-zip: 2.2.1
|
client-zip: 2.2.1
|
||||||
clsx: ^1.2.1
|
clsx: ^1.2.1
|
||||||
|
cookies-next: ^2.1.1
|
||||||
cross-env: 7.0.3
|
cross-env: 7.0.3
|
||||||
eslint: 8.27.0
|
eslint: 8.27.0
|
||||||
eslint-config-next: 13.0.3-canary.4
|
eslint-config-next: 13.0.3-canary.4
|
||||||
|
@ -33,7 +34,6 @@ specifiers:
|
||||||
react-dom: 18.2.0
|
react-dom: 18.2.0
|
||||||
react-dropzone: 14.2.3
|
react-dropzone: 14.2.3
|
||||||
react-hot-toast: ^2.4.0
|
react-hot-toast: ^2.4.0
|
||||||
react-loading-skeleton: 3.1.0
|
|
||||||
server-only: ^0.0.1
|
server-only: ^0.0.1
|
||||||
sharp: ^0.31.2
|
sharp: ^0.31.2
|
||||||
swr: 1.3.0
|
swr: 1.3.0
|
||||||
|
@ -52,6 +52,7 @@ dependencies:
|
||||||
bcrypt: 5.1.0
|
bcrypt: 5.1.0
|
||||||
client-zip: 2.2.1
|
client-zip: 2.2.1
|
||||||
clsx: 1.2.1
|
clsx: 1.2.1
|
||||||
|
cookies-next: 2.1.1
|
||||||
next: 13.0.3-canary.4_biqbaboplfbrettd7655fr4n2y
|
next: 13.0.3-canary.4_biqbaboplfbrettd7655fr4n2y
|
||||||
next-auth: 4.16.4_hsmqkug4agizydugca45idewda
|
next-auth: 4.16.4_hsmqkug4agizydugca45idewda
|
||||||
next-themes: /@wits/next-themes/0.2.7_hsmqkug4agizydugca45idewda
|
next-themes: /@wits/next-themes/0.2.7_hsmqkug4agizydugca45idewda
|
||||||
|
@ -61,7 +62,6 @@ dependencies:
|
||||||
react-dom: 18.2.0_react@18.2.0
|
react-dom: 18.2.0_react@18.2.0
|
||||||
react-dropzone: 14.2.3_react@18.2.0
|
react-dropzone: 14.2.3_react@18.2.0
|
||||||
react-hot-toast: 2.4.0_biqbaboplfbrettd7655fr4n2y
|
react-hot-toast: 2.4.0_biqbaboplfbrettd7655fr4n2y
|
||||||
react-loading-skeleton: 3.1.0_react@18.2.0
|
|
||||||
server-only: 0.0.1
|
server-only: 0.0.1
|
||||||
swr: 1.3.0_react@18.2.0
|
swr: 1.3.0_react@18.2.0
|
||||||
textarea-markdown-editor: 0.1.13_biqbaboplfbrettd7655fr4n2y
|
textarea-markdown-editor: 0.1.13_biqbaboplfbrettd7655fr4n2y
|
||||||
|
@ -728,6 +728,10 @@ packages:
|
||||||
'@types/node': 17.0.23
|
'@types/node': 17.0.23
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/cookie/0.4.1:
|
||||||
|
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@types/debug/4.1.7:
|
/@types/debug/4.1.7:
|
||||||
resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
|
resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -758,6 +762,10 @@ packages:
|
||||||
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
|
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/node/16.18.3:
|
||||||
|
resolution: {integrity: sha512-jh6m0QUhIRcZpNv7Z/rpN+ZWXOicUUQbSoWks7Htkbb9IjFQj4kzcX/xFCkjstCj5flMsN8FiSvt+q+Tcs4Llg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@types/node/17.0.23:
|
/@types/node/17.0.23:
|
||||||
resolution: {integrity: sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==}
|
resolution: {integrity: sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -1364,11 +1372,24 @@ packages:
|
||||||
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
|
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/cookie/0.4.2:
|
||||||
|
resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/cookie/0.5.0:
|
/cookie/0.5.0:
|
||||||
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
|
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/cookies-next/2.1.1:
|
||||||
|
resolution: {integrity: sha512-AZGZPdL1hU3jCjN2UMJTGhLOYzNUN9Gm+v8BdptYIHUdwz397Et1p+sZRfvAl8pKnnmMdX2Pk9xDRKCGBum6GA==}
|
||||||
|
dependencies:
|
||||||
|
'@types/cookie': 0.4.1
|
||||||
|
'@types/node': 16.18.3
|
||||||
|
cookie: 0.4.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
/copy-anything/2.0.6:
|
/copy-anything/2.0.6:
|
||||||
resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==}
|
resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -4327,14 +4348,6 @@ packages:
|
||||||
/react-is/16.13.1:
|
/react-is/16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
/react-loading-skeleton/3.1.0_react@18.2.0:
|
|
||||||
resolution: {integrity: sha512-j1U1CWWs68nBPOg7tkQqnlFcAMFF6oEK6MgqAo15f8A5p7mjH6xyKn2gHbkcimpwfO0VQXqxAswnSYVr8lWzjw==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '>=16.8.0'
|
|
||||||
dependencies:
|
|
||||||
react: 18.2.0
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/react-onclickoutside/6.12.2_biqbaboplfbrettd7655fr4n2y:
|
/react-onclickoutside/6.12.2_biqbaboplfbrettd7655fr4n2y:
|
||||||
resolution: {integrity: sha512-NMXGa223OnsrGVp5dJHkuKxQ4czdLmXSp5jSV9OqiCky9LOpPATn3vLldc+q5fK3gKbEHvr7J1u0yhBh/xYkpA==}
|
resolution: {integrity: sha512-NMXGa223OnsrGVp5dJHkuKxQ4czdLmXSp5jSV9OqiCky9LOpPATn3vLldc+q5fK3gKbEHvr7J1u0yhBh/xYkpA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
previewFeatures = ["referentialIntegrity"]
|
previewFeatures = ["referentialIntegrity", "fullTextSearch"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|
Loading…
Reference in a new issue