dep bumps, add pages feature, bug fixes, type improvements

This commit is contained in:
Max Leiter 2023-02-23 20:35:25 -08:00
parent e21d896669
commit fec58f2465
96 changed files with 645 additions and 384 deletions

View file

@ -2,7 +2,7 @@
import { startTransition, Suspense, useState } from "react"
import styles from "./auth.module.css"
import Link from "../../components/link"
import Link from "../../../components/link"
import { signIn } from "next-auth/react"
import Input from "@components/input"
import Button from "@components/button"

View file

@ -9,7 +9,7 @@ export function ErrorQueryParamsHandler() {
const { setToast } = useToasts()
useEffect(() => {
if (queryParams.get("error")) {
if (queryParams?.get("error")) {
setToast({
message: queryParams.get("error") as string,
type: "error"

View file

@ -59,7 +59,10 @@ function FileDropdown({
return (
<Popover>
<Popover.Trigger className={buttonStyles.button}>
<Popover.Trigger
className={buttonStyles.button}
style={{ height: 40, padding: 10 }}
>
<div
className={clsx(buttonStyles.icon, styles.chevron)}
style={{ marginRight: 6 }}

View file

@ -1,7 +1,7 @@
"use client"
import * as RadixTabs from "@radix-ui/react-tabs"
import FormattingIcons from "src/app/(posts)/new/components/edit-document-list/edit-document/formatting-icons"
import FormattingIcons from "src/app/(drift)/(posts)/new/components/edit-document-list/edit-document/formatting-icons"
import { ChangeEvent, ClipboardEvent, useRef } from "react"
import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor"
import Preview, { StaticPreview } from "../preview"

View file

@ -17,7 +17,7 @@ function Description({ onChange, description }: props) {
label="Description"
maxLength={256}
width="100%"
placeholder="An optional description of your post"
placeholder="An optional description"
/>
</div>
)

View file

@ -2,7 +2,7 @@ import { ChangeEvent, ClipboardEvent, useCallback } from "react"
import styles from "./document.module.css"
import Button from "@components/button"
import Input from "@components/input"
import DocumentTabs from "src/app/(posts)/components/tabs"
import DocumentTabs from "src/app/(drift)/(posts)/components/tabs"
import { Trash } from "react-feather"
type Props = {

View file

@ -6,10 +6,10 @@ import generateUUID from "@lib/generate-uuid"
import styles from "./post.module.css"
import EditDocumentList from "./edit-document-list"
import { ChangeEvent } from "react"
import getTitleForPostCopy from "@lib/get-title-for-post-copy"
import getTitleForPostCopy from "src/app/lib/get-title-for-post-copy"
import Description from "./description"
import { PostWithFiles } from "@lib/server/prisma"
import PasswordModal from "../../../components/password-modal"
import PasswordModal from "../../../../components/password-modal"
import Title from "./title"
import FileDropzone from "./drag-and-drop"
import Button from "@components/button"
@ -35,16 +35,14 @@ export type Document = {
}
function Post({
initialPost: stringifiedInitialPost,
initialPost,
newPostParent
}: {
initialPost?: string
initialPost?: PostWithFiles
newPostParent?: string
}): JSX.Element | null {
const { isAuthenticated } = useSessionSWR()
const parsedPost = JSON.parse(stringifiedInitialPost || "{}") as PostWithFiles
const initialPost = parsedPost?.id ? parsedPost : null
const { setToast } = useToasts()
const router = useRouter()
const [title, setTitle] = useState(

View file

@ -1,6 +1,10 @@
import NewPost from "../../components/new"
import { notFound, redirect } from "next/navigation"
import { getPostById } from "@lib/server/prisma"
import {
getPostById,
serverPostToClientPost,
ServerPostWithFiles
} from "@lib/server/prisma"
import { getSession } from "@lib/server/session"
async function NewFromExisting({
@ -21,7 +25,7 @@ async function NewFromExisting({
return notFound()
}
const post = await getPostById(id, {
const post = (await getPostById(id, {
select: {
authorId: true,
title: true,
@ -35,11 +39,11 @@ async function NewFromExisting({
}
}
}
})
})) as ServerPostWithFiles
const serialized = JSON.stringify(post)
const clientPost = post ? serverPostToClientPost(post) : undefined
return <NewPost initialPost={serialized} newPostParent={id} />
return <NewPost initialPost={clientPost} newPostParent={id} />
}
export default NewFromExisting

View file

@ -0,0 +1,3 @@
export default function NewLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

View file

@ -1,4 +1,4 @@
import NewPost from "src/app/(posts)/new/components/new"
import NewPost from "src/app/(drift)/(posts)/new/components/new"
import "./react-datepicker.css"
export default function New() {

View file

@ -2,28 +2,21 @@
import Button from "@components/button"
import ButtonGroup from "@components/button-group"
import FileDropdown from "src/app/(posts)/components/file-dropdown"
import FileDropdown from "src/app/(drift)/(posts)/components/file-dropdown"
import { Edit, ArrowUpCircle, Archive } from "react-feather"
import styles from "./post-buttons.module.css"
import { useRouter } from "next/navigation"
import { PostWithFiles } from "@lib/server/prisma"
export const PostButtons = ({
title,
files,
loading,
postId,
parentId
post,
loading
}: {
title: string
files?: Pick<PostWithFiles, "files">["files"]
post?: PostWithFiles
loading?: boolean
postId?: string
parentId?: string
visibility?: string
authorId?: string
}) => {
const router = useRouter()
const { files, id: postId, parentId, title } = post || {}
const downloadClick = async () => {
if (!files?.length) return
const downloadZip = (await import("client-zip")).downloadZip

View file

@ -2,27 +2,19 @@ import CreatedAgoBadge from "@components/badges/created-ago-badge"
import ExpirationBadge from "@components/badges/expiration-badge"
import VisibilityBadge from "@components/badges/visibility-badge"
import Skeleton from "@components/skeleton"
import { PostWithFilesAndAuthor } from "@lib/server/prisma"
import styles from "./title.module.css"
type TitleProps = {
title: string
loading?: boolean
displayName?: string
visibility?: string
createdAt?: string
expiresAt?: string
authorId?: string
post?: PostWithFilesAndAuthor
}
export const PostTitle = ({
title,
displayName,
visibility,
createdAt,
expiresAt,
loading
}: // authorId
TitleProps) => {
export const PostTitle = ({ post, loading }: TitleProps) => {
const { title, author, visibility, createdAt, expiresAt } = post || {}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore displayName should be present
const displayName = author?.displayName
return (
<span className={styles.title}>
<h1

View file

@ -8,16 +8,12 @@ import PasswordModalWrapper from "./password-modal-wrapper"
import { PostWithFilesAndAuthor } from "@lib/server/prisma"
type Props = {
post: string | PostWithFilesAndAuthor
post: PostWithFilesAndAuthor
isProtected?: boolean
isAuthor?: boolean
}
const PostFiles = ({ post: _initialPost }: Props) => {
const initialPost =
typeof _initialPost === "string"
? (JSON.parse(_initialPost) as PostWithFilesAndAuthor)
: _initialPost
const PostFiles = ({ post: initialPost }: Props) => {
const [post, setPost] = useState<PostWithFilesAndAuthor>(initialPost)
const router = useRouter()
@ -63,15 +59,13 @@ const PostFiles = ({ post: _initialPost }: Props) => {
gap: "var(--gap-double)"
}}
>
{post?.files?.map(({ id, content, title, html }) => (
{post?.files?.map((file) => (
<DocumentComponent
skeleton={false}
key={id}
title={title}
key={post.id}
initialTab={"preview"}
id={id}
content={content}
preview={html}
file={file}
post={post}
/>
))}
</main>

View file

@ -1,6 +1,6 @@
"use client"
import { Post, PostWithFilesAndAuthor } from "@lib/server/prisma"
import { PostWithFilesAndAuthor } from "@lib/server/prisma"
import PasswordModal from "@components/password-modal"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
@ -10,8 +10,8 @@ import { fetchWithUser } from "src/app/lib/fetch-with-user"
type Props = {
setPost: (post: PostWithFilesAndAuthor) => void
postId: Post["id"]
authorId: Post["authorId"]
postId: PostWithFilesAndAuthor["id"]
authorId: PostWithFilesAndAuthor["authorId"]
}
const PasswordModalWrapper = ({ setPost, postId, authorId }: Props) => {
@ -20,7 +20,9 @@ const PasswordModalWrapper = ({ setPost, postId, authorId }: Props) => {
const { session, isLoading } = useSessionSWR()
const isAuthor = isLoading
? undefined
: session?.user && session?.user?.id === authorId
: session?.user
? session?.user?.id === authorId
: false
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
const onSubmit = useCallback(
async (password: string) => {
@ -64,14 +66,14 @@ const PasswordModalWrapper = ({ setPost, postId, authorId }: Props) => {
}
useEffect(() => {
if (isAuthor) {
if (isAuthor === true) {
onSubmit("author")
setToast({
message:
"You're the author of this post, so you automatically have access to it.",
type: "default"
})
} else {
} else if (isAuthor === false) {
setIsPasswordModalOpen(true)
}
}, [isAuthor, onSubmit, setToast])

View file

@ -1,21 +1,20 @@
"use client"
import Button from "@components/button"
import ButtonGroup from "@components/button-group"
import Skeleton from "@components/skeleton"
import Tooltip from "@components/tooltip"
import DocumentTabs from "src/app/(posts)/components/tabs"
import DocumentTabs from "src/app/(drift)/(posts)/components/tabs"
import Link from "next/link"
import { memo } from "react"
import { Download, ExternalLink } from "react-feather"
import { Download, ExternalLink, Globe } from "react-feather"
import styles from "./document.module.css"
import { getURLFriendlyTitle } from "src/app/lib/get-url-friendly-title"
import { PostWithFiles, ServerPost } from "@lib/server/prisma"
import { isAllowedVisibilityForWebpage } from "@lib/constants"
type SharedProps = {
title?: string
initialTab: "edit" | "preview"
id?: string
content?: string
preview?: string
file?: PostWithFiles["files"][0]
post?: Pick<ServerPost, "id" | "title" | "visibility">
}
type Props = (
@ -28,7 +27,13 @@ type Props = (
) &
SharedProps
const DownloadButtons = ({ rawLink }: { rawLink?: string }) => {
const DownloadButtons = ({
rawLink,
siteLink
}: {
rawLink?: string
siteLink?: string
}) => {
return (
<ButtonGroup>
<Tooltip content="Download">
@ -44,15 +49,28 @@ const DownloadButtons = ({ rawLink }: { rawLink?: string }) => {
/>
</Link>
</Tooltip>
<Tooltip content="Open raw in new tab">
<Link href={rawLink || ""} target="_blank" rel="noopener noreferrer">
<Button
iconLeft={<ExternalLink color="var(--fg)" />}
aria-label="Open raw file in new tab"
style={{ border: "none", background: "transparent" }}
/>
</Link>
</Tooltip>
{rawLink ? (
<Tooltip content="Open raw in new tab">
<Link href={rawLink || ""} target="_blank" rel="noopener noreferrer">
<Button
iconLeft={<ExternalLink color="var(--fg)" />}
aria-label="Open raw file in new tab"
style={{ border: "none", background: "transparent" }}
/>
</Link>
</Tooltip>
) : null}
{siteLink ? (
<Tooltip content="Open as webpage">
<Link href={siteLink || ""} target="_blank" rel="noopener noreferrer">
<Button
iconLeft={<Globe color="var(--fg)" />}
aria-label="Open as webpage"
style={{ border: "none", background: "transparent" }}
/>
</Link>
</Tooltip>
) : null}
</ButtonGroup>
)
}
@ -78,14 +96,14 @@ const Document = ({ skeleton, ...props }: Props) => {
)
}
const { title, initialTab, id, content = "", preview } = props
const { file, post } = props
// if the query has our title, we can use it to scroll to the file.
// we can't use the browsers implementation because the data isn't loaded yet
if (title && typeof window !== "undefined") {
if (file?.title && typeof window !== "undefined") {
const hash = window.location.hash
if (hash && hash === `#${title}`) {
const element = document.getElementById(title)
if (file && hash && hash === `#${file?.title}`) {
const element = document.getElementById(file.title)
if (element) {
element.scrollIntoView()
}
@ -95,28 +113,35 @@ const Document = ({ skeleton, ...props }: Props) => {
return (
<>
<div className={styles.card}>
<header id={title}>
<header id={file?.title}>
<Link
href={`#${title}`}
href={`#${file?.title}`}
aria-label="File"
style={{
textDecoration: "none",
color: "var(--fg)"
}}
>
{title}
{file?.title}
</Link>
{/* TODO: switch to api once next.js bug is fixed */}
{/* Not /api/ because of rewrites defined in next.config.mjs */}
<DownloadButtons rawLink={`/api/file/raw/${id}`} />
<DownloadButtons
rawLink={`/api/file/raw/${file?.id}`}
siteLink={
file && post && isAllowedVisibilityForWebpage(post.visibility)
? `/pages/${file.id}/${getURLFriendlyTitle(file?.title || "")}`
: undefined
}
/>
</header>
<div className={styles.documentContainer}>
<DocumentTabs
defaultTab={initialTab}
staticPreview={preview}
defaultTab={props.initialTab}
staticPreview={file?.html}
isEditing={false}
>
{content}
{file?.content || ""}
</DocumentTabs>
</div>
</div>

View file

@ -0,0 +1,20 @@
"use client"
import { createContext, useContext } from "react"
import { getPost } from "./get-post"
const PostContext = createContext<
Awaited<ReturnType<typeof getPost>> | null
>(null)
export const PostProvider = PostContext.Provider
export const usePost = () => {
const post = useContext(PostContext)
if (!post) {
throw new Error("usePost must be used within a PostProvider")
}
return post
}

View file

@ -41,26 +41,24 @@ export const getPost = cache(async (id: string) => {
}
if (post.visibility === "public" || post.visibility === "unlisted") {
return { post }
return post
}
if (post.visibility === "private") {
const user = await getCurrentUser()
if (user?.id === post.authorId || user?.role === "admin") {
return { post }
return post
}
return redirect("/new")
}
if (post.visibility === "protected") {
return {
post: {
visibility: "protected",
authorId: post.authorId,
id: post.id
}
}
}
return { post }
return post
})

View file

@ -0,0 +1,40 @@
import {
PostWithFilesAndAuthor,
serverPostToClientPost,
ServerPostWithFilesAndAuthor
} from "@lib/server/prisma"
import ScrollToTop from "@components/scroll-to-top"
import { PostButtons } from "./components/header/post-buttons"
import styles from "./layout.module.css"
import { PostTitle } from "./components/header/title"
import { getPost } from "./get-post"
export default async function PostLayout({
children,
params
}: {
children: React.ReactNode
params: {
id: string
}
}) {
const post = (await getPost(params.id)) as ServerPostWithFilesAndAuthor
// TODO: type-safe
const clientPost = serverPostToClientPost(post) as PostWithFilesAndAuthor
return (
<div className={styles.root}>
<div className={styles.header}>
{post.visibility !== "protected" && <PostButtons post={clientPost} />}
{post.visibility !== "protected" && <PostTitle post={clientPost} />}
</div>
{post.description && (
<div>
<p>{post.description}</p>
</div>
)}
<ScrollToTop />
{children}
</div>
)
}

View file

@ -1,4 +1,9 @@
import VisibilityControl from "@components/badges/visibility-control"
import {
PostWithFilesAndAuthor,
serverPostToClientPost,
ServerPostWithFilesAndAuthor
} from "@lib/server/prisma"
import PostFiles from "./components/post-files"
import { getPost } from "./get-post"
@ -9,11 +14,12 @@ export default async function PostPage({
id: string
}
}) {
const { post } = await getPost(params.id)
const stringifiedPost = JSON.stringify(post)
const post = (await getPost(params.id)) as ServerPostWithFilesAndAuthor
const clientPost = serverPostToClientPost(post) as PostWithFilesAndAuthor
return (
<>
<PostFiles post={stringifiedPost} />
<PostFiles post={clientPost} />
<VisibilityControl
authorId={post.authorId}
postId={post.id}

View file

@ -3,7 +3,7 @@
import Button from "@components/button"
import { Spinner } from "@components/spinner"
import { useToasts } from "@components/toasts"
import { Post, User } from "@lib/server/prisma"
import { ServerPostWithFilesAndAuthor, UserWithPosts } from "@lib/server/prisma"
import Link from "next/link"
import { useState } from "react"
import { fetchWithUser } from "src/app/lib/fetch-with-user"
@ -14,7 +14,7 @@ export function UserTable({
}: {
users?: {
createdAt: string
posts?: Post[]
posts?: ServerPostWithFilesAndAuthor[]
id: string
email: string | null
role: string | null
@ -95,7 +95,7 @@ export function PostTable({
posts?: {
createdAt: string
id: string
author?: User | null
author?: UserWithPosts | null
title: string
visibility: string
}[]

View file

@ -1,10 +1,11 @@
import { getCurrentUser } from "@lib/server/session"
import { redirect } from "next/navigation"
import { PropsWithChildren } from "react"
export default async function AdminLayout({
children
}: PropsWithChildren<unknown>) {
}: {
children: React.ReactNode
}) {
const user = await getCurrentUser()
const isAdmin = user?.role === "admin"

View file

@ -1,4 +1,10 @@
import { getAllPosts, getAllUsers } from "@lib/server/prisma"
import {
getAllPosts,
getAllUsers,
PostWithFiles,
serverPostToClientPost,
ServerPostWithFiles
} from "@lib/server/prisma"
import { PostTable, UserTable } from "./components/tables"
export default async function AdminPage() {
@ -25,15 +31,9 @@ export default async function AdminPage() {
const [users, posts] = await Promise.all([usersPromise, postsPromise])
const serializedPosts = posts.map((post) => {
return {
...post,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
expiresAt: post.expiresAt?.toISOString(),
deletedAt: post.deletedAt?.toISOString()
}
})
const serializedPosts = posts.map((post) =>
serverPostToClientPost(post as ServerPostWithFiles)
) as PostWithFiles[]
const serializedUsers = users.map((user) => {
return {
@ -46,7 +46,8 @@ export default async function AdminPage() {
<div>
<h1>Admin</h1>
<h2>Users</h2>
<UserTable users={serializedUsers} />
{/* @ts-expect-error Type 'unknown' is not assignable to type */}
<UserTable users={serializedUsers as unknown} />
<h2>Posts</h2>
<PostTable posts={serializedPosts} />
</div>

View file

@ -1,5 +1,9 @@
import PostList from "@components/post-list"
import { getPostsByUser, getUserById } from "@lib/server/prisma"
import {
getPostsByUser,
getUserById,
serverPostToClientPost
} from "@lib/server/prisma"
import Image from "next/image"
import { Suspense } from "react"
import { User } from "react-feather"
@ -11,15 +15,10 @@ async function PostListWrapper({
posts: ReturnType<typeof getPostsByUser>
userId: string
}) {
const data = (await posts).filter((post) => post.visibility === "public")
return (
<PostList
userId={userId}
initialPosts={JSON.stringify(data)}
hideSearch
hideActions
/>
)
const data = (await posts)
.filter((post) => post.visibility === "public")
.map(serverPostToClientPost)
return <PostList userId={userId} initialPosts={data} hideSearch hideActions />
}
export default async function UserPage({

View file

@ -4,14 +4,16 @@ import Layout from "@components/layout"
import { Toasts } from "@components/toasts"
import Header from "@components/header"
import { Inter } from "@next/font/google"
import { PropsWithChildren, Suspense } from "react"
import { Suspense } from "react"
import { Spinner } from "@components/spinner"
const inter = Inter({ subsets: ["latin"], variable: "--inter-font" })
export default async function RootLayout({
children
}: PropsWithChildren<unknown>) {
}: {
children: React.ReactNode
}) {
return (
// suppressHydrationWarning is required because of next-themes
<html lang="en" className={inter.variable} suppressHydrationWarning>

View file

@ -1,5 +1,5 @@
import { redirect } from "next/navigation"
import { getPostsByUser } from "@lib/server/prisma"
import { getPostsByUser, serverPostToClientPost } from "@lib/server/prisma"
import PostList from "@components/post-list"
import { getCurrentUser } from "@lib/server/session"
import { authOptions } from "@lib/server/auth"
@ -12,14 +12,12 @@ export default async function Mine() {
return redirect(authOptions.pages?.signIn || "/new")
}
const posts = await getPostsByUser(userId, true)
const stringifiedPosts = JSON.stringify(posts)
const posts = (await getPostsByUser(userId, true)).map(serverPostToClientPost)
return (
<Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}>
<PostList
userId={userId}
initialPosts={stringifiedPosts}
initialPosts={posts}
isOwner={true}
hideSearch={false}
/>

View file

@ -2,7 +2,11 @@ import Image from "next/image"
import Card from "@components/card"
import { getWelcomeContent } from "src/pages/api/welcome"
import DocumentTabs from "./(posts)/components/tabs"
import { getAllPosts } from "@lib/server/prisma"
import {
getAllPosts,
serverPostToClientPost,
ServerPostWithFilesAndAuthor
} from "@lib/server/prisma"
import PostList, { NoPostsFound } from "@components/post-list"
import { cache, Suspense } from "react"
import ErrorBoundary from "@components/error/fallback"
@ -34,12 +38,7 @@ export default async function Page() {
<ErrorBoundary>
<Suspense
fallback={
<PostList
skeleton
hideActions
hideSearch
initialPosts={JSON.stringify({})}
/>
<PostList skeleton hideActions hideSearch initialPosts={[]} />
}
>
{/* @ts-expect-error because of async RSC */}
@ -67,14 +66,14 @@ async function WelcomePost() {
}
async function PublicPostList() {
const posts = await getAllPosts({
const posts = (await getAllPosts({
select: {
id: true,
title: true,
createdAt: true,
author: {
select: {
name: true
displayName: true
}
},
visibility: true,
@ -92,13 +91,13 @@ async function PublicPostList() {
orderBy: {
createdAt: "desc"
}
})
})) as unknown as ServerPostWithFilesAndAuthor[]
if (posts.length === 0) {
return <NoPostsFound />
}
return (
<PostList initialPosts={JSON.stringify(posts)} hideActions hideSearch />
)
const clientPosts = posts.map((post) => serverPostToClientPost(post))
return <PostList initialPosts={clientPosts} hideActions hideSearch />
}

View file

@ -6,11 +6,7 @@ import { ThemeProvider } from "next-themes"
import { PropsWithChildren } from "react"
import { SWRConfig } from "swr"
export type ChildrenProps = {
children?: React.ReactNode
}
export function Providers({ children }: ChildrenProps) {
export function Providers({ children }: PropsWithChildren<unknown>) {
return (
<SessionProvider>
<RadixTooltip.Provider delayDuration={200}>

View file

@ -1,6 +1,6 @@
import SettingsGroup from "../components/settings-group"
import Profile from "src/app/settings/components/sections/profile"
import SettingsGroup from "../../components/settings-group"
import APIKeys from "./components/sections/api-keys"
import Profile from "./components/sections/profile"
export default async function SettingsPage() {
return (

View file

@ -1,5 +0,0 @@
import { ChildrenProps } from "src/app/providers"
export default function NewLayout({ children }: ChildrenProps) {
return <>{children}</>
}

View file

@ -1,57 +0,0 @@
import { PostWithFilesAndAuthor } from "@lib/server/prisma"
import ScrollToTop from "@components/scroll-to-top"
import { title } from "process"
import { PostButtons } from "./components/header/post-buttons"
import styles from "./layout.module.css"
import { PostTitle } from "./components/header/title"
import { getPost } from "./get-post"
import { PropsWithChildren } from "react"
export default async function PostLayout({
children,
params
}: PropsWithChildren<{
params: {
id: string
}
}>) {
const { post } = (await getPost(params.id)) as {
post: PostWithFilesAndAuthor
}
return (
<div className={styles.root}>
<div className={styles.header}>
{/* post.title is not set when the post is protected */}
{post.title && (
<PostButtons
parentId={post.parentId || undefined}
postId={post.id}
files={post.files}
title={title}
authorId={post.authorId}
visibility={post.visibility}
/>
)}
{post.title && (
<PostTitle
title={post.title}
createdAt={post.createdAt.toString()}
expiresAt={post.expiresAt?.toString()}
// displayName is an optional param
displayName={post.author?.displayName || undefined}
visibility={post.visibility}
authorId={post.authorId}
/>
)}
</div>
{post.description && (
<div>
<p>{post.description}</p>
</div>
)}
<ScrollToTop />
{children}
</div>
)
}

View file

@ -8,11 +8,11 @@ import Badge from "../badge"
const ExpirationBadge = ({
postExpirationDate
}: {
postExpirationDate: Date | string | null
postExpirationDate: Date | string | undefined
onExpires?: () => void
}) => {
const expirationDate = useMemo(
() => (postExpirationDate ? new Date(postExpirationDate) : null),
() => (postExpirationDate ? new Date(postExpirationDate) : undefined),
[postExpirationDate]
)
const [timeUntilString, setTimeUntil] = useState<string | null>(

View file

@ -9,6 +9,7 @@ import { Spinner } from "@components/spinner"
import { useRouter } from "next/navigation"
import { useSessionSWR } from "@lib/use-session-swr"
import { fetchWithUser } from "src/app/lib/fetch-with-user"
import FadeIn from "@components/fade-in"
type Props = {
authorId: string
@ -87,7 +88,7 @@ function VisibilityControl({
}
return (
<>
<FadeIn>
<ButtonGroup
style={{
maxWidth: 600,
@ -128,7 +129,7 @@ function VisibilityControl({
onClose={onClosePasswordModal}
onSubmit={submitPassword}
/>
</>
</FadeIn>
)
}

View file

@ -3,8 +3,6 @@
cursor: pointer;
border-radius: var(--radius);
border: 1px solid var(--border);
/* padding: var(--gap-half) var(--gap); */
color: var(--darker-gray);
}
.button:hover,

View file

@ -1,9 +1,9 @@
@media (prefers-reduced-motion: no-preference) {
.fadeIn {
animation-name: fadeInAnimation;
animation-fill-mode: backwards;
}
/* @media (prefers-reduced-motion: no-preference) { */
.fadeIn {
animation-name: fadeInAnimation;
animation-fill-mode: backwards;
}
/* } */
@keyframes fadeInAnimation {
from {

View file

@ -1,8 +1,18 @@
"use client"
import { PropsWithChildren } from "react"
import clsx from "clsx"
import styles from "./page.module.css"
export default function Layout({ children }: PropsWithChildren<unknown>) {
return <div className={styles.page}>{children}</div>
export default function Layout({
children,
forSites
}: {
forSites?: boolean
children: React.ReactNode
}) {
return (
<div className={clsx(styles.page, forSites && styles.forSites)}>
{children}
</div>
)
}

View file

@ -8,6 +8,10 @@
margin: 0 auto;
}
.forSites {
margin-top: var(--gap);
}
/* 55rem == --main-content */
@media screen and (max-width: 55rem) {
.page {

View file

@ -13,7 +13,7 @@ import { fetchWithUser } from "src/app/lib/fetch-with-user"
import { Stack } from "@components/stack"
type Props = {
initialPosts: string | PostWithFiles[]
initialPosts: PostWithFiles[]
morePosts?: boolean
hideSearch?: boolean
hideActions?: boolean
@ -24,17 +24,13 @@ type Props = {
}
const PostList = ({
initialPosts: initialPostsMaybeJSON,
initialPosts,
hideSearch,
hideActions,
isOwner,
skeleton,
userId
}: Props) => {
const initialPosts =
typeof initialPostsMaybeJSON === "string"
? JSON.parse(initialPostsMaybeJSON)
: initialPostsMaybeJSON
const [searchValue, setSearchValue] = useState("")
const [searching, setSearching] = useState(false)
const [posts, setPosts] = useState<PostWithFiles[]>(initialPosts)

View file

@ -8,6 +8,12 @@ import styles from "./scroll.module.css"
const ScrollToTop = () => {
const [shouldShow, setShouldShow] = useState(false)
const isReducedMotion =
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false
useEffect(() => {
// if user is scrolled, set visible
const handleScroll = () => {
@ -17,13 +23,8 @@ const ScrollToTop = () => {
return () => window.removeEventListener("scroll", handleScroll)
}, [])
const isReducedMotion =
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false
const onClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.blur()
window.scrollTo({ top: 0, behavior: isReducedMotion ? "auto" : "smooth" })
}

View file

@ -1,4 +1,5 @@
import Card from "@components/card"
"use client"
import * as RadixTooltip from "@radix-ui/react-tooltip"
import styles from "./tooltip.module.css"
@ -17,8 +18,9 @@ const Tooltip = ({
<RadixTooltip.Trigger asChild className={className}>
{children}
</RadixTooltip.Trigger>
<RadixTooltip.Content>
<Card className={styles.tooltip}>{content}</Card>
<div className={styles.tooltip}>{content}</div>
</RadixTooltip.Content>
</RadixTooltip.Root>
)

View file

@ -1,24 +1,32 @@
.tooltip {
animation: fadein 300ms;
animation: fadein 300ms;
background: var(--bg);
border-radius: var(--radius);
padding: 0 var(--gap);
border: 1px solid var(--border);
}
[data-side='top'] .tooltip{
margin-bottom: var(--gap-quarter);
[data-side="top"] .tooltip {
margin-bottom: var(--gap-quarter);
}
.tooltip[data-side='bottom'] {
margin-top: var(--gap-quarter);
.tooltip[data-side="bottom"] {
margin-top: var(--gap-quarter);
}
.tooltip[data-side='left'] {
margin-right: var(--gap-quarter);
.tooltip[data-side="left"] {
margin-right: var(--gap-quarter);
}
.tooltip[data-side='right'] {
margin-left: var(--gap-quarter);
.tooltip[data-side="right"] {
margin-left: var(--gap-quarter);
}
@keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View file

@ -0,0 +1,3 @@
export function getURLFriendlyTitle(title: string) {
return title.replace(/\s/g, "-")
}

View file

@ -0,0 +1,25 @@
import "@styles/globals.css"
import "@styles/markdown.css"
import Layout from "@components/layout"
import { Inter } from "@next/font/google"
import ThemeProvider from "./theme-provider"
const inter = Inter({ subsets: ["latin"], variable: "--inter-font" })
export default async function RootLayout({
children
}: {
children: React.ReactNode
}) {
return (
// suppressHydrationWarning is required because of next-themes
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body>
<ThemeProvider>
<Layout forSites>{children}</Layout>
</ThemeProvider>
</body>
</html>
)
}

View file

@ -0,0 +1,89 @@
import {
ALLOWED_VISIBILITIES_FOR_WEBPAGE,
isAllowedVisibilityForWebpage
} from "@lib/constants"
import {
getAllPosts,
getFileById,
getPostById,
ServerPostWithFiles
} from "@lib/server/prisma"
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { getURLFriendlyTitle } from "src/app/lib/get-url-friendly-title"
export default async function FilePage({
params
}: {
params: {
fileId: string
}
}) {
const { fileId: id } = params
const file = await getFileById(id)
if (!file || !isAllowedVisibilityForWebpage(file.post.visibility)) {
return notFound()
}
return (
<>
<h1 style={{ color: "var(--gray)" }}>{file.title}</h1>
<hr />
<article
dangerouslySetInnerHTML={{ __html: file.html.toString("utf-8") }}
/>
</>
)
}
export async function generateStaticParams() {
const posts = (await getAllPosts({
select: {
id: true,
files: {
// include where visibility is public or unlisted
// and title ends with .md
where: {
title: {
endsWith: ".md"
}
}
}
},
where: {
visibility: {
in: ALLOWED_VISIBILITIES_FOR_WEBPAGE
}
}
})) as ServerPostWithFiles[]
return posts.flatMap((post) => {
return post.files.map((file) => ({
fileId: file.id,
fileTitle: getURLFriendlyTitle(file.title)
}))
})
}
export async function generateMetadata({
params
}: {
params: {
fileId: string
}
}): Promise<Metadata> {
const { fileId: postId } = params
const post = await getPostById(postId, {
select: {
description: true,
title: true
}
})
return {
title: post?.title || "",
description: post?.description || ""
}
}

View file

@ -0,0 +1,14 @@
"use client"
import { PropsWithChildren } from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export default function ThemeProvider({
children
}: PropsWithChildren<unknown>) {
return (
<NextThemesProvider enableSystem defaultTheme="dark">
{children}
</NextThemesProvider>
)
}

View file

@ -122,6 +122,10 @@ table th {
table th,
table td {
padding: 0.5rem 1rem;
padding: 0.35rem 0.75rem;
border: 1px solid var(--light-gray);
}
article > :not(:first-child) {
margin-top: 1.5rem;
}

View file

@ -74,7 +74,9 @@ export const config = (env: Environment): Config => {
is_production,
enable_admin: stringToBoolean(env.ENABLE_ADMIN),
registration_password: env.REGISTRATION_PASSWORD ?? "",
welcome_content: env.WELCOME_CONTENT ?? "Welcome to Drift.",
welcome_content:
env.WELCOME_CONTENT ??
"## Drift is a self-hostable clone of GitHub Gist.\n\nIt is a simple way to save and share code and text snippets, with support for the following:\n\n- Render GitHub Extended Markdown\n- User authentication\n- Private, public, and password protected posts\n- Syntax highlighting and language detection\n- Drag-and-drop file uploading \n\n You can find the source code and sponsor development on [GitHub](https://github.com/MaxLeiter/drift).",
welcome_title: env.WELCOME_TITLE ?? "Drift",
url:
throwIfUndefined("DRIFT_URL", true) ||

View file

@ -1,3 +1,12 @@
import { ServerPost } from "./server/prisma"
// Visibilties for the webpages feature
export const ALLOWED_VISIBILITIES_FOR_WEBPAGE = ["public", "unlisted"]
export function isAllowedVisibilityForWebpage(visibility: ServerPost["visibility"]) {
return ALLOWED_VISIBILITIES_FOR_WEBPAGE.includes(visibility)
}
// Code files for uploading with drag and drop and syntax highlighting
export const allowedFileTypes = [
"application/json",
"application/x-javascript",

View file

@ -1,11 +1,10 @@
import { Gist } from "./types"
import * as crypto from "crypto"
import type { Post } from "@lib/server/prisma"
import { getHtmlFromFile } from "@lib/server/get-html-from-drift-file"
import { prisma } from "@lib/server/prisma"
import { prisma, ServerPost } from "@lib/server/prisma"
export type AdditionalPostInformation = Pick<
Post,
ServerPost,
"visibility" | "password" | "expiresAt"
> & {
userId: string
@ -14,7 +13,7 @@ export type AdditionalPostInformation = Pick<
export async function createPostFromGist(
{ userId, visibility, password, expiresAt }: AdditionalPostInformation,
gist: Gist
): Promise<Post> {
): Promise<ServerPost> {
const files = Object.values(gist.files)
const [title, description] = gist.description.split("\n", 1)

View file

@ -4,11 +4,20 @@ declare global {
}
import config from "@lib/config"
import { Post, PrismaClient, User, Prisma } from "@prisma/client"
import {
Post as ServerPost,
PrismaClient,
User as ServerUser,
Prisma,
File as ServerFile
} from "@prisma/client"
import * as crypto from "crypto"
import { cache } from "react"
export type { User, File, Post } from "@prisma/client"
export type {
User as ServerUser,
File as ServerFile,
Post as ServerPost
} from "@prisma/client"
export const prisma =
global.prisma ||
new PrismaClient({
@ -42,29 +51,91 @@ const postWithFilesAndAuthor = Prisma.validator<Prisma.PostArgs>()({
})
export type ServerPostWithFiles = Prisma.PostGetPayload<typeof postWithFiles>
export type PostWithAuthor = Prisma.PostGetPayload<typeof postWithAuthor>
export type ServerPostWithAuthor = Prisma.PostGetPayload<typeof postWithAuthor>
export type ServerPostWithFilesAndAuthor = Prisma.PostGetPayload<
typeof postWithFilesAndAuthor
>
export type PostWithFiles = Omit<ServerPostWithFiles, "files"> & {
files: (Omit<ServerPostWithFiles["files"][number], "content" | "html"> & {
export type PostWithFiles = Omit<
ServerPostWithFiles,
"files" | "updatedAt" | "createdAt" | "deletedAt" | "expiresAt"
> & {
files: (Omit<
ServerPostWithFiles["files"][number],
"content" | "html" | "updatedAt" | "createdAt" | "deletedAt"
> & {
content: string
html: string
updatedAt?: string
createdAt: string
deletedAt?: string
})[]
updatedAt?: string
createdAt: string
deletedAt?: string
expiresAt?: string
}
export type PostWithFilesAndAuthor = Omit<
ServerPostWithFilesAndAuthor,
"files"
"files" | "updatedAt" | "createdAt" | "deletedAt" | "expiresAt" | "author"
> & {
files: (Omit<
ServerPostWithFilesAndAuthor["files"][number],
"content" | "html"
"content" | "html" | "updatedAt" | "createdAt" | "deletedAt"
> & {
content: string
html: string
updatedAt?: string
createdAt: string
deletedAt?: string
})[]
author: Omit<
ServerPostWithFilesAndAuthor["author"],
"createdAt" | "updatedAt"
> & {
createdAt: string
updatedAt: string
}
updatedAt?: string
createdAt: string
deletedAt?: string
expiresAt?: string
}
export function serverPostToClientPost(
post: ServerPostWithFiles | ServerPostWithFilesAndAuthor
): PostWithFilesAndAuthor | PostWithFiles {
let result: PostWithFiles | PostWithFilesAndAuthor = {
...post,
files: post.files?.map((file) => ({
...file,
content: file.content?.toString("utf-8"),
html: file.html?.toString("utf-8"),
updatedAt: file.updatedAt?.toISOString(),
createdAt: file.createdAt?.toISOString(),
deletedAt: file.deletedAt?.toISOString()
})),
updatedAt: post.updatedAt?.toISOString(),
createdAt: post.createdAt?.toISOString(),
deletedAt: post.deletedAt?.toISOString(),
expiresAt: post.expiresAt?.toISOString()
}
if ("author" in post && post.author) {
result = {
...result,
author: {
...post.author,
createdAt: post.author.createdAt?.toISOString(),
updatedAt: post.author.updatedAt?.toISOString()
}
}
}
return result
}
export const getFilesForPost = async (postId: string) => {
@ -87,12 +158,15 @@ export async function getFilesByPost(postId: string) {
return files
}
export async function getPostsByUser(userId: string): Promise<Post[]>
export async function getPostsByUser(userId: string): Promise<ServerPost[]>
export async function getPostsByUser(
userId: string,
includeFiles: true
): Promise<ServerPostWithFiles[]>
export async function getPostsByUser(userId: User["id"], withFiles?: boolean) {
export async function getPostsByUser(
userId: ServerUser["id"],
withFiles?: boolean
) {
const posts = await prisma.post.findMany({
where: {
authorId: userId
@ -123,7 +197,7 @@ export async function getPostsByUser(userId: User["id"], withFiles?: boolean) {
}
export const getUserById = async (
userId: User["id"],
userId: ServerUser["id"],
selects?: Prisma.UserFindUniqueArgs["select"]
) => {
const user = await prisma.user.findUnique({
@ -143,7 +217,7 @@ export const getUserById = async (
return user
}
export const isUserAdmin = async (userId: User["id"]) => {
export const isUserAdmin = async (userId: ServerUser["id"]) => {
const user = await prisma.user.findUnique({
where: {
id: userId
@ -185,36 +259,24 @@ type GetPostByIdOptions = Pick<
"include" | "rejectOnNotFound" | "select"
>
export const getPostById = async (
postId: Post["id"],
options?: GetPostByIdOptions
): Promise<Post | PostWithFiles | PostWithFilesAndAuthor | null> => {
const post = await prisma.post.findUnique({
where: {
id: postId
},
...options
})
export const getPostById = cache(
async (postId: ServerPost["id"], options?: GetPostByIdOptions) => {
const post = await prisma.post.findUnique({
where: {
id: postId
},
...options
})
if (post) {
if ("files" in post) {
// @ts-expect-error TODO: fix types so files can exist
post.files = post.files.map((file) => ({
...file,
content: file.content ? file.content.toString() : undefined,
html: file.html ? file.html.toString() : undefined
}))
}
return post
}
return post
}
)
export const getAllPosts = cache(
async (
options?: Prisma.PostFindManyArgs
): Promise<
Post[] | ServerPostWithFiles[] | ServerPostWithFilesAndAuthor[]
ServerPost[] | ServerPostWithFiles[] | ServerPostWithFilesAndAuthor[]
> => {
const posts = await prisma.post.findMany(options)
return posts
@ -231,7 +293,7 @@ export type UserWithPosts = Prisma.UserGetPayload<typeof userWithPosts>
export const getAllUsers = async (
options?: Prisma.UserFindManyArgs
): Promise<User[] | UserWithPosts[]> => {
): Promise<ServerUser[] | UserWithPosts[]> => {
const users = (await prisma.user.findMany({
select: {
id: true,
@ -242,7 +304,7 @@ export const getAllUsers = async (
createdAt: true
},
...options
})) as User[] | UserWithPosts[]
})) as ServerUser[] | UserWithPosts[]
return users
}
@ -252,7 +314,7 @@ export const searchPosts = async (
{
userId
}: {
userId?: User["id"]
userId?: ServerUser["id"]
} = {}
): Promise<ServerPostWithFiles[]> => {
const posts = await prisma.post.findMany({
@ -287,7 +349,10 @@ function generateApiToken() {
return crypto.randomBytes(32).toString("hex")
}
export const createApiToken = async (userId: User["id"], name: string) => {
export const createApiToken = async (
userId: ServerUser["id"],
name: string
) => {
const apiToken = await prisma.apiToken.create({
data: {
token: generateApiToken(),
@ -301,3 +366,19 @@ export const createApiToken = async (userId: User["id"], name: string) => {
return apiToken
}
export function getFileById(fileId: ServerFile["id"]) {
return prisma.file.findUnique({
where: {
id: fileId
},
include: {
post: {
select: {
id: true,
visibility: true
}
}
}
})
}

View file

@ -2,7 +2,7 @@ import { withMethods } from "@lib/api-middleware/with-methods"
import { prisma } from "@lib/server/prisma"
import { NextApiRequest, NextApiResponse } from "next"
import { File } from "@lib/server/prisma"
import { ServerFile } from "@lib/server/prisma"
import * as crypto from "crypto"
import { getHtmlFromFile } from "@lib/server/get-html-from-drift-file"
import { verifyApiUser } from "@lib/server/verify-api-user"
@ -14,7 +14,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse<unknown>) {
return res.status(401).json({ error: "Unauthorized" })
}
const files = req.body.files as (Omit<File, "content" | "html"> & {
const files = req.body.files as (Omit<ServerFile, "content" | "html"> & {
content: string
html: string
})[]