use custom badge component, add post deletion

This commit is contained in:
Max Leiter 2022-11-13 23:02:31 -08:00
parent 5f4749ebb3
commit 97cff7eb53
35 changed files with 418 additions and 230 deletions

View file

@ -1,3 +1,8 @@
{
"extends": "next/core-web-vitals"
"extends": "next/core-web-vitals",
"settings": {
"next": {
"rootDir": "client/"
}
}
}

View file

@ -2,6 +2,8 @@ import { memo, useEffect, useState } from "react"
import styles from "./preview.module.css"
import "@styles/markdown.css"
import "@styles/syntax.css"
import Skeleton from "@components/skeleton"
import { Spinner } from "@geist-ui/core/dist"
type Props = {
height?: number | string
@ -47,11 +49,11 @@ const MarkdownPreview = ({
}
fetchPost()
}, [initial, fileId, title])
return (
<>
{isLoading ? (
<div>Loading...</div>
<><Spinner /></>
) : (
<StaticPreview content={content} height={height} />
)}

View file

@ -99,7 +99,7 @@ function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
>
<input {...getInputProps()} />
{!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>}
</div>

View file

@ -88,7 +88,6 @@ const Document = ({
return (
<>
<Spacer height={1} />
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Input

View file

@ -284,31 +284,24 @@ const Post = ({
Add a File
</Button>
<div className={styles.rightButtons}>
{
<DatePicker
onChange={onChangeExpiration}
customInput={
<Input
label="Expires at"
clearable
width="100%"
height="40px"
/>
}
placeholderText="Won't expire"
selected={expiresAt}
showTimeInput={true}
// @ts-ignore
customTimeInput={<CustomTimeInput />}
timeInputLabel="Time:"
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()}
/>
}
<DatePicker
onChange={onChangeExpiration}
customInput={
<Input label="Expires at" clearable width="100%" height="40px" />
}
placeholderText="Won't expire"
selected={expiresAt}
showTimeInput={true}
// @ts-ignore
customTimeInput={<CustomTimeInput />}
timeInputLabel="Time:"
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.Item main onClick={() => onSubmit("unlisted")}>
Create Unlisted

View file

@ -19,13 +19,13 @@ import VisibilityControl from "@components/badges/visibility-control"
import { File, PostWithFiles } from "@lib/server/prisma"
type Props = {
post: PostWithFiles
post: string | PostWithFiles
isProtected?: boolean
isAuthor?: boolean
}
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 [isExpired, setIsExpired] = useState(
post.expiresAt ? new Date(post.expiresAt) < new Date() : null
@ -50,7 +50,7 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
if (post.expiresAt) {
interval = setInterval(() => {
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
setIsExpired(expirationDate < new Date())
if (expirationDate < new Date()) setIsExpired(true)
}, 4000)
}
return () => {
@ -128,7 +128,7 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
</ButtonGroup>
</span>
<span className={styles.title}>
<Text h3>{post.title}</Text>
<h3>{post.title}</h3>
<span className={styles.badges}>
<VisibilityBadge visibility={visibility} />
<CreatedAgoBadge createdAt={post.createdAt} />
@ -138,7 +138,7 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
</div>
{post.description && (
<div>
<Text p>{post.description}</Text>
<p>{post.description}</p>
</div>
)}
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}

View file

@ -1,8 +1,8 @@
import { memo, useRef, useState } from "react"
import { memo, useRef } from "react"
import styles from "./document.module.css"
import Download from "@geist-ui/icons/download"
import ExternalLink from "@geist-ui/icons/externalLink"
import Skeleton from "react-loading-skeleton"
import Skeleton from "@components/skeleton"
import Link from "next/link"
import {
@ -71,15 +71,12 @@ const Document = ({
id
}: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab)
// const height = editable ? "500px" : '100%'
const height = "100%"
const handleTabChange = (newTab: string) => {
if (newTab === "edit") {
codeEditorRef.current?.focus()
}
setTab(newTab as "edit" | "preview")
}
const rawLink = () => {

View file

@ -1,23 +1,25 @@
import type { GetServerSideProps } from "next"
import type { Post } from "@lib/types"
import PostPage from "app/(posts)/post/[id]/components/post-page"
import { notFound } from "next/navigation"
import { getAllPosts, getPostById } from "@lib/server/prisma"
import { getCurrentUser, getSession } from "@lib/server/session"
import { getCurrentUser } from "@lib/server/session"
export type PostProps = {
post: Post
isProtected?: boolean
}
export async function generateStaticParams() {
const posts = await getAllPosts()
// export async function generateStaticParams() {
// const posts = await getAllPosts({
// where: {
// visibility: "public"
// }
// })
return posts.map((post) => ({
id: post.id
}))
}
// return posts.map((post) => ({
// id: post.id
// }))
// }
const getPost = async (id: string) => {
const post = await getPostById(id, true)
@ -30,7 +32,7 @@ const getPost = async (id: string) => {
const isAuthor = user?.id === post?.authorId
if (post.visibility === "public") {
return { post, isAuthor, signedIn: Boolean(user) }
return { post, isAuthor }
}
// must be authed to see unlisted/private
@ -49,12 +51,11 @@ const getPost = async (id: string) => {
return {
post,
isProtected: true,
isAuthor,
signedIn: Boolean(user)
isAuthor
}
}
return { post, isAuthor, signedIn: Boolean(user) }
return { post, isAuthor }
}
const PostView = async ({
@ -64,8 +65,10 @@ const PostView = async ({
id: string
}
}) => {
const { post, isProtected, isAuthor, signedIn } = await getPost(params.id)
return <PostPage isAuthor={isAuthor} isProtected={isProtected} post={post} />
const { post, isProtected, isAuthor } = await getPost(params.id)
// 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 ({

View file

@ -1,7 +1,5 @@
import { isUserAdmin } from "@lib/server/prisma"
import { getCurrentUser } from "@lib/server/session"
import Admin from "./components/admin"
import { cookies } from "next/headers"
import { notFound } from "next/navigation"
const AdminPage = async () => {

View 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;
}

View 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

View file

@ -1,7 +1,7 @@
import Tooltip from "@components/tooltip"
import { Badge } from "@geist-ui/core/dist"
import { timeAgo } from "@lib/time-ago"
import { useMemo, useState, useEffect } from "react"
import Badge from "../badge"
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
@ -19,7 +19,7 @@ const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
<Badge type="secondary">
{" "}
<Tooltip content={formattedTime}>
<>Created {time}</>
<>{time}</>
</Tooltip>
</Badge>
)

View file

@ -1,7 +1,7 @@
import Tooltip from "@components/tooltip"
import { Badge } from "@geist-ui/core/dist"
import { timeUntil } from "@lib/time-ago"
import { useEffect, useMemo, useState } from "react"
import Badge from "../badge"
const ExpirationBadge = ({
postExpirationDate
@ -36,7 +36,7 @@ const ExpirationBadge = ({
}, [expirationDate])
const isExpired = useMemo(() => {
return timeUntilString && timeUntilString === "in 0 seconds"
return timeUntilString === "in 0 seconds"
}, [timeUntilString])
// useEffect(() => {

View file

@ -1,5 +1,5 @@
import { Badge } from "@geist-ui/core/dist"
import type { PostVisibility } from "@lib/types"
import Badge from "../badge"
type CastPostVisibility = PostVisibility | string
@ -8,18 +8,7 @@ type Props = {
}
const VisibilityBadge = ({ visibility }: Props) => {
const getBadgeType = () => {
switch (visibility) {
case "public":
return "success"
case "private":
return "warning"
case "unlisted":
return "default"
}
}
return <Badge type={getBadgeType()}>{visibility}</Badge>
return <Badge type={"primary"}>{visibility}</Badge>
}
export default VisibilityBadge

View file

@ -1,3 +1,4 @@
import config from "@lib/config"
import React from "react"
type PageSeoProps = {
@ -14,11 +15,74 @@ const PageSeo = ({
}: PageSeoProps) => {
return (
<>
<title>Drift - {title}</title>
<title>Drift{title ? ` - ${title}` : ""}</title>
<meta charSet="utf-8" />
{!isPrivate && <meta name="description" content={description} />}
{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
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} />
</>
)

View file

@ -8,19 +8,25 @@ import ListItem from "./list-item"
import { ChangeEvent, useCallback, useEffect, useState } from "react"
import useDebounce from "@lib/hooks/use-debounce"
import Link from "@components/link"
import { TOKEN_COOKIE_NAME } from "@lib/constants"
import type { PostWithFiles } from "@lib/server/prisma"
import DriftTooltip from "@components/tooltip"
import { Search } from "@geist-ui/icons"
type Props = {
initialPosts: PostWithFiles[]
initialPosts: string | PostWithFiles[]
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 [posts, setPosts] = useState(initialPosts)
const [posts, setPosts] = useState<PostWithFiles[]>(initialPosts)
const [searching, setSearching] = useState(false)
const [hasMorePosts, setHasMorePosts] = useState(morePosts)
@ -51,54 +57,39 @@ const PostList = ({ morePosts, initialPosts }: Props) => {
// update posts on search
useEffect(() => {
if (debouncedSearchValue) {
// fetch results from /server-api/posts/search
const fetchResults = async () => {
setSearching(true)
//encode search
setSearching(true)
async function fetchPosts() {
const res = await fetch(
`/server-api/posts/search?q=${encodeURIComponent(
`/api/post/search?q=${encodeURIComponent(
debouncedSearchValue
)}`,
)}&userId=${userId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json"
// "tok": process.env.SECRET_KEY || ''
}
}
)
const data = await res.json()
setPosts(data)
const json = await res.json()
setPosts(json.posts)
setSearching(false)
}
fetchResults()
fetchPosts()
} else {
setPosts(initialPosts)
}
}, [initialPosts, debouncedSearchValue])
// TODO: fix cyclical dependency issue
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchValue, userId])
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value)
}
// const debouncedSearchHandler = useMemo(
// () => debounce(handleSearchChange, 300),
// []
// )
// useEffect(() => {
// return () => {
// debouncedSearchHandler.cancel()
// }
// }, [debouncedSearchHandler])
const deletePost = useCallback(
(postId: string) => async () => {
const res = await fetch(`/server-api/posts/${postId}`, {
const res = await fetch(`/api/post/${postId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json"
}
})
if (!res.ok) {
@ -116,14 +107,13 @@ const PostList = ({ morePosts, initialPosts }: Props) => {
<div className={styles.searchContainer}>
<Input
scale={3 / 2}
clearable
placeholder="Search..."
onChange={handleSearchChange}
disabled={Boolean(!posts?.length)}
/>
</div>
{!posts && <Text type="error">Failed to load.</Text>}
{!posts.length && searching && (
{!posts?.length && searching && (
<ul>
<li>
<ListItemSkeleton />

View file

@ -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"
const ListItemSkeleton = () => (

View file

@ -17,6 +17,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: var(--gap-quarter) 0;
}
@media screen and (max-width: 700px) {

View file

@ -1,11 +1,5 @@
import VisibilityBadge from "../badges/visibility-badge"
import {
Text,
Card,
Divider,
Badge,
Button
} from "@geist-ui/core/dist"
import { Text, Card, Divider, Button } from "@geist-ui/core/dist"
import FadeIn from "@components/fade-in"
import Trash from "@geist-ui/icons/trash"
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 { File } from "@lib/server/prisma"
import Tooltip from "@components/tooltip"
import Badge from "@components/badges/badge"
// TODO: isOwner should default to false so this can be used generically
const ListItem = ({
@ -45,14 +40,16 @@ const ListItem = ({
<li key={post.id}>
<Card style={{ overflowY: "scroll" }}>
<Card.Body>
<Text h3 className={styles.title}>
<Link
colored
style={{ marginRight: "var(--gap)" }}
href={`/post/${post.id}`}
>
{post.title}
</Link>
<div className={styles.title}>
<h3 style={{ display: "inline-block" }}>
<Link
colored
style={{ marginRight: "var(--gap)" }}
href={`/post/${post.id}`}
>
{post.title}
</Link>
</h3>
{isOwner && (
<span className={styles.buttons}>
{post.parentId && (
@ -72,22 +69,20 @@ const ListItem = ({
</Tooltip>
</span>
)}
</Text>
</div>
{post.description && (
<Text p className={styles.oneline}>
{post.description}
</Text>
<p className={styles.oneline}>{post.description}</p>
)}
<div className={styles.badges}>
<VisibilityBadge visibility={post.visibility as PostVisibility} />
<CreatedAgoBadge createdAt={post.createdAt} />
<Badge type="secondary">
{post.files?.length === 1
? "1 file"
: `${post.files?.length || 0} files`}
</Badge>
<CreatedAgoBadge createdAt={post.createdAt} />
<ExpirationBadge postExpirationDate={post.expiresAt} />
</div>
</Card.Body>

View 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 }} />
}

View file

@ -0,0 +1,4 @@
.skeleton {
background-color: var(--lighter-gray);
border-radius: var(--radius);
}

View file

@ -9,50 +9,18 @@ interface RootLayoutProps {
}
export default async function RootLayout({ children }: RootLayoutProps) {
// TODO: this opts out of SSG
const cookiesList = cookies();
const hasNextAuth = cookiesList.get("next-auth.session-token") !== undefined;
return (
<ServerThemeProvider
cookieName="drift-theme"
disableTransitionOnChange
attribute="data-theme"
enableColorScheme
>
<html lang="en">
<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>
<body className={styles.main}>
<LayoutWrapper signedIn={hasNextAuth}>{children}</LayoutWrapper>

View file

@ -14,5 +14,6 @@ export default async function Mine() {
const posts = await getPostsByUser(userId, true)
const hasMore = false
return <PostList morePosts={hasMore} initialPosts={posts} />
const stringifiedPosts = JSON.stringify(posts)
return <PostList userId={userId} morePosts={hasMore} initialPosts={stringifiedPosts} />
}

View file

@ -3,7 +3,6 @@
import Header from "@components/header"
import { CssBaseline, GeistProvider, Page, Themes } from "@geist-ui/core/dist"
import { ThemeProvider } from "next-themes"
import { SkeletonTheme } from "react-loading-skeleton"
import * as RadixTooltip from "@radix-ui/react-tooltip"
export function LayoutWrapper({
@ -56,24 +55,19 @@ export function LayoutWrapper({
return (
<RadixTooltip.Provider delayDuration={200}>
<GeistProvider themes={[customTheme]} themeType={"custom"}>
<SkeletonTheme
baseColor={skeletonBaseColor}
highlightColor={skeletonHighlightColor}
<ThemeProvider
disableTransitionOnChange
cookieName="drift-theme"
attribute="data-theme"
>
<ThemeProvider
disableTransitionOnChange
cookieName="drift-theme"
attribute="data-theme"
>
<CssBaseline />
<Page width={"100%"}>
<Page.Header>
<Header signedIn={signedIn} />
</Page.Header>
{children}
</Page>
</ThemeProvider>
</SkeletonTheme>
<CssBaseline />
<Page width={"100%"}>
<Page.Header>
<Header signedIn={signedIn} />
</Page.Header>
{children}
</Page>
</ThemeProvider>
</GeistProvider>
</RadixTooltip.Provider>
)

View file

@ -7,7 +7,6 @@ import { useState } from "react"
const Profile = ({ user }: { user: User }) => {
const [name, setName] = useState<string>(user.name || "")
const [email, setEmail] = useState<string>(user.email || "")
const [bio, setBio] = useState<string>()
const { setToast } = useToasts()
@ -16,17 +15,13 @@ const Profile = ({ user }: { user: User }) => {
setName(e.target.value)
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
}
const handleBioChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBio(e.target.value)
}
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!name && !email && !bio) {
if (!name && !bio) {
setToast({
text: "Please fill out at least one field",
type: "error"
@ -36,7 +31,6 @@ const Profile = ({ user }: { user: User }) => {
const data = {
displayName: name,
email,
bio
}
@ -92,8 +86,8 @@ const Profile = ({ user }: { user: User }) => {
htmlType="email"
width={"100%"}
placeholder="my@email.io"
value={email || ""}
onChange={handleEmailChange}
value={user.email || undefined}
disabled
/>
</div>
<div>

View file

@ -111,7 +111,9 @@ input,
button,
textarea,
select {
border: var(--border);
font-size: 1rem;
background: var(--bg);
}
blockquote {

View file

@ -1,8 +1,3 @@
.keyword {
font-weight: bold;
color: var(--darker-gray);
}
.token.operator,
.token.punctuation,
.token.builtin,
@ -10,6 +5,7 @@
color: var(--token);
}
.token.keyword,
.token.string,
.token.number,
.token.boolean {

View file

@ -30,21 +30,25 @@ export const config = (env: Environment): Config => {
}
}
const throwIfUndefined = (str: EnvironmentValue, name: string): string => {
if (str === undefined) {
throw new Error(`Missing environment variable: ${name}`)
// TODO: improve `key` type
const throwIfUndefined = (key: keyof Environment): string => {
const value = env[key]
if (value === undefined) {
throw new Error(`Missing environment variable: ${key}`)
}
return str
return value
}
const defaultIfUndefined = (
str: EnvironmentValue,
str: string,
defaultValue: string
): string => {
if (str === undefined) {
const value = env[str]
if (value === undefined) {
return defaultValue
}
return str
return value
}
const validNodeEnvs = (str: EnvironmentValue) => {
@ -61,12 +65,11 @@ export const config = (env: Environment): Config => {
const is_production = env.NODE_ENV === "production"
const developmentDefault = (
str: EnvironmentValue,
name: string,
defaultValue: string
): string => {
if (is_production) return throwIfUndefined(str, name)
return defaultIfUndefined(str, defaultValue)
if (is_production) return throwIfUndefined(name)
return defaultIfUndefined(name, defaultValue)
}
validNodeEnvs(env.NODE_ENV)
@ -78,11 +81,11 @@ export const config = (env: Environment): Config => {
is_production,
memory_db: stringToBoolean(env.MEMORY_DB),
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 ?? "",
welcome_content: env.WELCOME_CONTENT ?? "",
welcome_title: env.WELCOME_TITLE ?? "",
url: "http://localhost:3000",
url: throwIfUndefined("DRIFT_URL"),
GITHUB_CLIENT_ID: env.GITHUB_CLIENT_ID ?? "",
GITHUB_CLIENT_SECRET: env.GITHUB_CLIENT_SECRET ?? "",
}

View file

@ -3,7 +3,7 @@ declare global {
}
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
// 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) => {
if (item.createdAt) {
item.createdAt = item.createdAt.toISOString()
item.createdAt = item.createdAt.toString()
}
if (item.updatedAt) {
item.updatedAt = item.updatedAt.toISOString()
item.updatedAt = item.updatedAt.toString()
}
if (item.expiresAt) {
item.expiresAt = item.expiresAt.toISOString()
item.expiresAt = item.expiresAt.toString()
}
if (item.deletedAt) {
item.deletedAt = item.deletedAt.toISOString()
item.deletedAt = item.deletedAt.toString()
}
return item
}
@ -40,6 +40,11 @@ export const prisma =
log: ["query"]
})
// prisma.$use(async (params, next) => {
// const result = await next(params)
// return updateDates(result)
// })
if (process.env.NODE_ENV !== "production") global.prisma = prisma
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
}
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({
include: {
files: withFiles
},
// 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[]

View file

@ -4,8 +4,8 @@ import { NextResponse } from "next/server"
export default withAuth(
async function middleware(req) {
console.log("middleware")
const token = await getToken({ req })
const isAuth = !!token
const isAuthPage =
req.nextUrl.pathname.startsWith("/signup") ||

View file

@ -22,6 +22,7 @@
"bcrypt": "^5.1.0",
"client-zip": "2.2.1",
"clsx": "^1.2.1",
"cookies-next": "^2.1.1",
"next": "13.0.3-canary.4",
"next-auth": "^4.16.4",
"next-themes": "npm:@wits/next-themes@0.2.7",
@ -31,7 +32,6 @@
"react-dom": "18.2.0",
"react-dropzone": "14.2.3",
"react-hot-toast": "^2.4.0",
"react-loading-skeleton": "3.1.0",
"server-only": "^0.0.1",
"swr": "1.3.0",
"textarea-markdown-editor": "0.1.13"

View file

@ -9,9 +9,10 @@ import * as crypto from "crypto"
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "GET") return handleGet(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>) {
const id = parseQueryParam(req.query.id)
@ -114,3 +115,34 @@ async function handlePut(req: NextApiRequest, res: NextApiResponse<any>) {
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" })
}

View 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)

View file

@ -17,6 +17,7 @@ specifiers:
bcrypt: ^5.1.0
client-zip: 2.2.1
clsx: ^1.2.1
cookies-next: ^2.1.1
cross-env: 7.0.3
eslint: 8.27.0
eslint-config-next: 13.0.3-canary.4
@ -33,7 +34,6 @@ specifiers:
react-dom: 18.2.0
react-dropzone: 14.2.3
react-hot-toast: ^2.4.0
react-loading-skeleton: 3.1.0
server-only: ^0.0.1
sharp: ^0.31.2
swr: 1.3.0
@ -52,6 +52,7 @@ dependencies:
bcrypt: 5.1.0
client-zip: 2.2.1
clsx: 1.2.1
cookies-next: 2.1.1
next: 13.0.3-canary.4_biqbaboplfbrettd7655fr4n2y
next-auth: 4.16.4_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-dropzone: 14.2.3_react@18.2.0
react-hot-toast: 2.4.0_biqbaboplfbrettd7655fr4n2y
react-loading-skeleton: 3.1.0_react@18.2.0
server-only: 0.0.1
swr: 1.3.0_react@18.2.0
textarea-markdown-editor: 0.1.13_biqbaboplfbrettd7655fr4n2y
@ -728,6 +728,10 @@ packages:
'@types/node': 17.0.23
dev: true
/@types/cookie/0.4.1:
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
dev: false
/@types/debug/4.1.7:
resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
dependencies:
@ -758,6 +762,10 @@ packages:
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
dev: false
/@types/node/16.18.3:
resolution: {integrity: sha512-jh6m0QUhIRcZpNv7Z/rpN+ZWXOicUUQbSoWks7Htkbb9IjFQj4kzcX/xFCkjstCj5flMsN8FiSvt+q+Tcs4Llg==}
dev: false
/@types/node/17.0.23:
resolution: {integrity: sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==}
dev: true
@ -1364,11 +1372,24 @@ packages:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
dev: false
/cookie/0.4.2:
resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
engines: {node: '>= 0.6'}
dev: false
/cookie/0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
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:
resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==}
dependencies:
@ -4327,14 +4348,6 @@ packages:
/react-is/16.13.1:
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:
resolution: {integrity: sha512-NMXGa223OnsrGVp5dJHkuKxQ4czdLmXSp5jSV9OqiCky9LOpPATn3vLldc+q5fK3gKbEHvr7J1u0yhBh/xYkpA==}
peerDependencies:

View file

@ -1,6 +1,6 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["referentialIntegrity"]
previewFeatures = ["referentialIntegrity", "fullTextSearch"]
}
datasource db {