diff --git a/client/.eslintrc.json b/client/.eslintrc.json index bffb357a..c685e93e 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -1,3 +1,8 @@ { - "extends": "next/core-web-vitals" + "extends": "next/core-web-vitals", + "settings": { + "next": { + "rootDir": "client/" + } + } } diff --git a/client/app/(posts)/components/preview/index.tsx b/client/app/(posts)/components/preview/index.tsx index df973ce4..7eb8a0d3 100644 --- a/client/app/(posts)/components/preview/index.tsx +++ b/client/app/(posts)/components/preview/index.tsx @@ -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 ? ( -
Loading...
+ <> ) : ( )} diff --git a/client/app/(posts)/new/components/drag-and-drop/index.tsx b/client/app/(posts)/new/components/drag-and-drop/index.tsx index f764dc92..85654b12 100644 --- a/client/app/(posts)/new/components/drag-and-drop/index.tsx +++ b/client/app/(posts)/new/components/drag-and-drop/index.tsx @@ -99,7 +99,7 @@ function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) { > {!isDragActive && ( - Drag some files here, or {verb} to select files +

Drag some files here, or {verb} to select files

)} {isDragActive && Release to drop the files here} diff --git a/client/app/(posts)/new/components/edit-document-list/edit-document/index.tsx b/client/app/(posts)/new/components/edit-document-list/edit-document/index.tsx index 962ef182..0430a591 100644 --- a/client/app/(posts)/new/components/edit-document-list/edit-document/index.tsx +++ b/client/app/(posts)/new/components/edit-document-list/edit-document/index.tsx @@ -88,7 +88,6 @@ const Document = ({ return ( <> -
- { - - } - placeholderText="Won't expire" - selected={expiresAt} - showTimeInput={true} - // @ts-ignore - 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()} - /> - } + + } + placeholderText="Won't expire" + selected={expiresAt} + showTimeInput={true} + // @ts-ignore + 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()} + /> onSubmit("unlisted")}> Create Unlisted diff --git a/client/app/(posts)/post/[id]/components/post-page/index.tsx b/client/app/(posts)/post/[id]/components/post-page/index.tsx index 73864158..a8635efd 100644 --- a/client/app/(posts)/post/[id]/components/post-page/index.tsx +++ b/client/app/(posts)/post/[id]/components/post-page/index.tsx @@ -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(initialPost) + const [post, setPost] = useState(typeof initialPost === "string" ? JSON.parse(initialPost) : initialPost) const [visibility, setVisibility] = useState(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) => { - {post.title} +

{post.title}

@@ -138,7 +138,7 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
{post.description && (
- {post.description} +

{post.description}

)} {/* {post.files.length > 1 && } */} diff --git a/client/app/(posts)/post/[id]/components/post-page/view-document/index.tsx b/client/app/(posts)/post/[id]/components/post-page/view-document/index.tsx index 8047b1b1..c0584c35 100644 --- a/client/app/(posts)/post/[id]/components/post-page/view-document/index.tsx +++ b/client/app/(posts)/post/[id]/components/post-page/view-document/index.tsx @@ -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(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 = () => { diff --git a/client/app/(posts)/post/[id]/page.tsx b/client/app/(posts)/post/[id]/page.tsx index 64985690..a80a8a3b 100644 --- a/client/app/(posts)/post/[id]/page.tsx +++ b/client/app/(posts)/post/[id]/page.tsx @@ -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 + 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 } // export const getServerSideProps: GetServerSideProps = async ({ diff --git a/client/app/admin/page.tsx b/client/app/admin/page.tsx index 52a2a66e..23b18d53 100644 --- a/client/app/admin/page.tsx +++ b/client/app/admin/page.tsx @@ -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 () => { diff --git a/client/app/components/badges/badge.module.css b/client/app/components/badges/badge.module.css new file mode 100644 index 00000000..78643d8a --- /dev/null +++ b/client/app/components/badges/badge.module.css @@ -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; +} diff --git a/client/app/components/badges/badge.tsx b/client/app/components/badges/badge.tsx new file mode 100644 index 00000000..241de983 --- /dev/null +++ b/client/app/components/badges/badge.tsx @@ -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 ( +
+
+ {children} +
+
+ ) +} + +export default Badge diff --git a/client/app/components/badges/created-ago-badge/index.tsx b/client/app/components/badges/created-ago-badge/index.tsx index 5ecb8fc3..799eaca8 100644 --- a/client/app/components/badges/created-ago-badge/index.tsx +++ b/client/app/components/badges/created-ago-badge/index.tsx @@ -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 }) => { {" "} - <>Created {time} + <>{time} ) diff --git a/client/app/components/badges/expiration-badge/index.tsx b/client/app/components/badges/expiration-badge/index.tsx index a4deb6a7..ef42f185 100644 --- a/client/app/components/badges/expiration-badge/index.tsx +++ b/client/app/components/badges/expiration-badge/index.tsx @@ -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(() => { diff --git a/client/app/components/badges/visibility-badge/index.tsx b/client/app/components/badges/visibility-badge/index.tsx index d90cc3f8..04e81f3f 100644 --- a/client/app/components/badges/visibility-badge/index.tsx +++ b/client/app/components/badges/visibility-badge/index.tsx @@ -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 {visibility} + return {visibility} } export default VisibilityBadge diff --git a/client/app/components/page-seo/index.tsx b/client/app/components/page-seo/index.tsx index 9dec0100..ae550560 100644 --- a/client/app/components/page-seo/index.tsx +++ b/client/app/components/page-seo/index.tsx @@ -1,3 +1,4 @@ +import config from "@lib/config" import React from "react" type PageSeoProps = { @@ -14,11 +15,74 @@ const PageSeo = ({ }: PageSeoProps) => { return ( <> - Drift - {title} + Drift{title ? ` - ${title}` : ""} + {!isPrivate && } {isPrivate && } + + {/* TODO: verify the correct meta tags */} + + + ) } export default PageSeo + +const ThemeAndIcons = () => ( + <> + + + + + + + + + + + +) + +const URLs = () => ( + <> + + + {/* TODO: OG image */} + + + + + + +) diff --git a/client/app/components/post-list/index.tsx b/client/app/components/post-list/index.tsx index 6fa0c2ad..aa2e0096 100644 --- a/client/app/components/post-list/index.tsx +++ b/client/app/components/post-list/index.tsx @@ -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(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) => { 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) => {
{!posts && Failed to load.} - {!posts.length && searching && ( + {!posts?.length && searching && (
  • diff --git a/client/app/components/post-list/list-item-skeleton.tsx b/client/app/components/post-list/list-item-skeleton.tsx index 71bd1a18..bd50f781 100644 --- a/client/app/components/post-list/list-item-skeleton.tsx +++ b/client/app/components/post-list/list-item-skeleton.tsx @@ -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 = () => ( diff --git a/client/app/components/post-list/list-item.module.css b/client/app/components/post-list/list-item.module.css index f3577667..5a934da0 100644 --- a/client/app/components/post-list/list-item.module.css +++ b/client/app/components/post-list/list-item.module.css @@ -17,6 +17,7 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin: var(--gap-quarter) 0; } @media screen and (max-width: 700px) { diff --git a/client/app/components/post-list/list-item.tsx b/client/app/components/post-list/list-item.tsx index 307b95a1..d74c8a28 100644 --- a/client/app/components/post-list/list-item.tsx +++ b/client/app/components/post-list/list-item.tsx @@ -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 = ({
  • - - - {post.title} - +
    +

    + + {post.title} + +

    {isOwner && ( {post.parentId && ( @@ -72,22 +69,20 @@ const ListItem = ({ )} - +
    {post.description && ( - - {post.description} - +

    {post.description}

    )}
    - {post.files?.length === 1 ? "1 file" : `${post.files?.length || 0} files`} +
    diff --git a/client/app/components/skeleton/index.tsx b/client/app/components/skeleton/index.tsx new file mode 100644 index 00000000..62506b37 --- /dev/null +++ b/client/app/components/skeleton/index.tsx @@ -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
    +} diff --git a/client/app/components/skeleton/skeleton.module.css b/client/app/components/skeleton/skeleton.module.css new file mode 100644 index 00000000..d86eb857 --- /dev/null +++ b/client/app/components/skeleton/skeleton.module.css @@ -0,0 +1,4 @@ +.skeleton { + background-color: var(--lighter-gray); + border-radius: var(--radius); +} diff --git a/client/app/layout.tsx b/client/app/layout.tsx index 522e0390..47dbba6e 100644 --- a/client/app/layout.tsx +++ b/client/app/layout.tsx @@ -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 ( - - - - - - - - - - - - Drift + {children} diff --git a/client/app/mine/page.tsx b/client/app/mine/page.tsx index 50926248..27ef448c 100644 --- a/client/app/mine/page.tsx +++ b/client/app/mine/page.tsx @@ -14,5 +14,6 @@ export default async function Mine() { const posts = await getPostsByUser(userId, true) const hasMore = false - return + const stringifiedPosts = JSON.stringify(posts) + return } diff --git a/client/app/root-layout-wrapper.tsx b/client/app/root-layout-wrapper.tsx index 8a755b0c..11e3acc9 100644 --- a/client/app/root-layout-wrapper.tsx +++ b/client/app/root-layout-wrapper.tsx @@ -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 ( - - - - - -
    - - {children} - - - + + + +
    + + {children} + + ) diff --git a/client/app/settings/components/sections/profile.tsx b/client/app/settings/components/sections/profile.tsx index 46ad3bc1..7dea3528 100644 --- a/client/app/settings/components/sections/profile.tsx +++ b/client/app/settings/components/sections/profile.tsx @@ -7,7 +7,6 @@ import { useState } from "react" const Profile = ({ user }: { user: User }) => { const [name, setName] = useState(user.name || "") - const [email, setEmail] = useState(user.email || "") const [bio, setBio] = useState() const { setToast } = useToasts() @@ -16,17 +15,13 @@ const Profile = ({ user }: { user: User }) => { setName(e.target.value) } - const handleEmailChange = (e: React.ChangeEvent) => { - setEmail(e.target.value) - } - const handleBioChange = (e: React.ChangeEvent) => { setBio(e.target.value) } const onSubmit = async (e: React.FormEvent) => { 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 />
    diff --git a/client/app/styles/globals.css b/client/app/styles/globals.css index 920afdb1..72ab8241 100644 --- a/client/app/styles/globals.css +++ b/client/app/styles/globals.css @@ -111,7 +111,9 @@ input, button, textarea, select { + border: var(--border); font-size: 1rem; + background: var(--bg); } blockquote { diff --git a/client/app/styles/syntax.css b/client/app/styles/syntax.css index 04a2e0da..77fbcf56 100644 --- a/client/app/styles/syntax.css +++ b/client/app/styles/syntax.css @@ -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 { diff --git a/client/lib/config.ts b/client/lib/config.ts index 766e1228..52d24bd7 100644 --- a/client/lib/config.ts +++ b/client/lib/config.ts @@ -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 ?? "", } diff --git a/client/lib/server/prisma.ts b/client/lib/server/prisma.ts index 288c2408..19048dd8 100644 --- a/client/lib/server/prisma.ts +++ b/client/lib/server/prisma.ts @@ -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 => { + 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[] diff --git a/client/middleware.ts b/client/middleware.ts index cfa6daa7..86a2b20c 100644 --- a/client/middleware.ts +++ b/client/middleware.ts @@ -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") || diff --git a/client/package.json b/client/package.json index 34d34ba6..ef240879 100644 --- a/client/package.json +++ b/client/package.json @@ -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" diff --git a/client/pages/api/post/[id].ts b/client/pages/api/post/[id].ts index db440a4d..9126d7a8 100644 --- a/client/pages/api/post/[id].ts +++ b/client/pages/api/post/[id].ts @@ -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) { const id = parseQueryParam(req.query.id) @@ -114,3 +115,34 @@ async function handlePut(req: NextApiRequest, res: NextApiResponse) { visibility: updatedPost.visibility }) } + +async function handleDelete(req: NextApiRequest, res: NextApiResponse) { + 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" }) +} diff --git a/client/pages/api/post/search.ts b/client/pages/api/post/search.ts new file mode 100644 index 00000000..12d43386 --- /dev/null +++ b/client/pages/api/post/search.ts @@ -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) diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 85e9d982..81a80418 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -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: diff --git a/client/prisma/schema.prisma b/client/prisma/schema.prisma index 07dc4007..61dae83f 100644 --- a/client/prisma/schema.prisma +++ b/client/prisma/schema.prisma @@ -1,6 +1,6 @@ generator client { provider = "prisma-client-js" - previewFeatures = ["referentialIntegrity"] + previewFeatures = ["referentialIntegrity", "fullTextSearch"] } datasource db {