add basic admin page, misc fixes

This commit is contained in:
Max Leiter 2022-12-04 01:31:51 -08:00
parent 9b593c849e
commit 56eefc8419
32 changed files with 552 additions and 322 deletions

View file

@ -5,12 +5,17 @@ import type { File } from "lib/server/prisma"
import styles from "./dropdown.module.css"
import buttonStyles from "@components/button/button.module.css"
import { ChevronDown, Code, File as FileIcon } from "react-feather"
import { Spinner } from "@components/spinner"
type Item = File & {
icon: JSX.Element
}
const FileDropdown = ({ files }: { files: File[] }) => {
const FileDropdown = ({ files, loading }: { files: File[], loading?: boolean }) => {
if (loading) {
return <Spinner />
}
const items = files.map((file) => {
const extension = file.title.split(".").pop()
if (codeFileExtensions.includes(extension || "")) {

View file

@ -1,6 +1,6 @@
import type { Document } from "../new"
import DocumentComponent from "./edit-document"
import { ChangeEvent, memo, useCallback } from "react"
import { ChangeEvent, useCallback } from "react"
const DocumentList = ({
docs,

View file

@ -0,0 +1,78 @@
"use client"
import Button from "@components/button"
import ButtonGroup from "@components/button-group"
import FileDropdown from "app/(posts)/components/file-dropdown"
import { Edit, ArrowUpCircle, Archive } from "react-feather"
import styles from "./post-buttons.module.css"
import { File } from "@prisma/client"
import { useRouter } from "next/navigation"
export const PostButtons = ({
title,
files,
loading,
postId,
parentId
}: {
title: string
files?: File[]
loading?: boolean
postId?: string
parentId?: string
}) => {
const router = useRouter()
const downloadClick = async () => {
if (!files?.length) return
const downloadZip = (await import("client-zip")).downloadZip
const blob = await downloadZip(
files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})
).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${title}.zip`
link.click()
link.remove()
}
const editACopy = () => {
router.push(`/new/from/${postId}`)
}
const viewParentClick = () => {
router.push(`/post/${parentId}`)
}
return (
<span className={styles.buttons}>
<ButtonGroup verticalIfMobile>
<Button
iconLeft={<Edit />}
onClick={editACopy}
style={{ textTransform: "none" }}
>
Edit a Copy
</Button>
{viewParentClick && (
<Button iconLeft={<ArrowUpCircle />} onClick={viewParentClick}>
View Parent
</Button>
)}
<Button
onClick={downloadClick}
iconLeft={<Archive />}
style={{ textTransform: "none" }}
>
Download as ZIP Archive
</Button>
<FileDropdown loading={loading} files={files || []} />
</ButtonGroup>
</span>
)
}

View file

@ -0,0 +1,12 @@
.buttons {
display: flex;
justify-content: flex-end;
margin-bottom: var(--gap);
}
@media screen and (max-width: 768px) {
.buttons {
display: flex;
justify-content: center;
}
}

View file

@ -0,0 +1,48 @@
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 styles from './title.module.css'
type TitleProps = {
title: string
loading?: boolean
displayName?: string
visibility?: string
createdAt?: Date
expiresAt?: Date
}
export const PostTitle = ({
title,
displayName,
visibility,
createdAt,
expiresAt,
loading
}: TitleProps) => {
return (
<span className={styles.title}>
<h3>
{title}{" "}
<span style={{ color: "var(--gray)" }}>
by {displayName || "anonymous"}
</span>
</h3>
{!loading && (
<span className={styles.badges}>
{visibility && <VisibilityBadge visibility={visibility} />}
{createdAt && <CreatedAgoBadge createdAt={createdAt} />}
{expiresAt && <ExpirationBadge postExpirationDate={expiresAt} />}
</span>
)}
{loading && (
<span className={styles.badges}>
<Skeleton width={100} height={20} />
<Skeleton width={100} height={20} />
<Skeleton width={100} height={20} />
</span>
)}
</span>
)
}

View file

@ -0,0 +1,35 @@
.title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title .badges {
display: flex;
gap: var(--gap-half);
flex-wrap: wrap;
}
.title h3 {
margin: 0;
padding: 0;
display: inline-block;
}
@media screen and (max-width: 768px) {
.title {
flex-direction: column;
gap: var(--gap-half);
}
.title .badges {
align-items: center;
justify-content: center;
}
.buttons {
display: flex;
justify-content: center;
}
}

View file

@ -1,21 +1,11 @@
"use client"
import VisibilityBadge from "@components/badges/visibility-badge"
import DocumentComponent from "./view-document"
import styles from "./post-page.module.css"
import { useEffect, useState } from "react"
import FileDropdown from "app/(posts)/components/file-dropdown"
import ScrollToTop from "@components/scroll-to-top"
import { useRouter } from "next/navigation"
import ExpirationBadge from "@components/badges/expiration-badge"
import CreatedAgoBadge from "@components/badges/created-ago-badge"
import PasswordModalPage from "./password-modal-wrapper"
import VisibilityControl from "@components/badges/visibility-control"
import { File, PostWithFilesAndAuthor } from "@lib/server/prisma"
import ButtonGroup from "@components/button-group"
import Button from "@components/button"
import { Archive, ArrowUpCircle, Edit } from "react-feather"
type Props = {
post: string | PostWithFilesAndAuthor
@ -28,7 +18,6 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
typeof initialPost === "string" ? JSON.parse(initialPost) : initialPost
)
const [visibility, setVisibility] = useState<string>(post.visibility)
const router = useRouter()
useEffect(() => {
@ -68,80 +57,8 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
return <PasswordModalPage setPost={setPost} postId={post.id} />
}
const download = async () => {
if (!post.files) return
const downloadZip = (await import("client-zip")).downloadZip
const blob = await downloadZip(
post.files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})
).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${post.title}.zip`
link.click()
link.remove()
}
const editACopy = () => {
router.push(`/new/from/${post.id}`)
}
const viewParentClick = () => {
router.push(`/post/${post.parentId}`)
}
return (
<>
<div className={styles.header}>
<span className={styles.buttons}>
<ButtonGroup verticalIfMobile>
<Button
iconLeft={<Edit />}
onClick={editACopy}
style={{ textTransform: "none" }}
>
Edit a Copy
</Button>
{post.parentId && (
<Button iconLeft={<ArrowUpCircle />} onClick={viewParentClick}>
View Parent
</Button>
)}
<Button
onClick={download}
iconLeft={<Archive />}
style={{ textTransform: "none" }}
>
Download as ZIP Archive
</Button>
<FileDropdown files={post.files || []} />
</ButtonGroup>
</span>
<span className={styles.title}>
<h3>
{post.title}{" "}
<span style={{ color: "var(--gray)" }}>
by {post.author?.displayName}
</span>
</h3>
<span className={styles.badges}>
<VisibilityBadge visibility={visibility} />
<CreatedAgoBadge createdAt={post.createdAt} />
<ExpirationBadge postExpirationDate={post.expiresAt} />
</span>
</span>
</div>
{post.description && (
<div>
<p>{post.description}</p>
</div>
)}
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
{post.files?.map(({ id, content, title, html }: File) => (
<DocumentComponent
key={id}
@ -152,16 +69,7 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
preview={html}
/>
))}
{isAuthor && (
<span className={styles.controls}>
<VisibilityControl
postId={post.id}
visibility={visibility}
setVisibility={setVisibility}
/>
</span>
)}
<ScrollToTop />
</>
)
}

View file

@ -1,3 +1,5 @@
'use client';
import { Post, PostWithFilesAndAuthor } from "@lib/server/prisma"
import PasswordModal from "@components/password-modal"
import { useRouter } from "next/navigation"

View file

@ -1,57 +0,0 @@
.header .title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.header .title .badges {
display: flex;
gap: var(--gap-half);
flex-wrap: wrap;
}
.header .title h3 {
margin: 0;
padding: 0;
display: inline-block;
}
.header .buttons {
display: flex;
justify-content: flex-end;
margin-bottom: var(--gap);
}
.controls {
display: flex;
justify-content: flex-end;
}
@media screen and (max-width: 900px) {
.header {
flex-direction: column;
gap: var(--gap);
}
}
@media screen and (max-width: 768px) {
.header .title {
flex-direction: column;
gap: var(--gap-half);
}
.header .title .badges {
align-items: center;
justify-content: center;
}
.header .buttons {
display: flex;
justify-content: center;
}
.controls {
justify-content: center;
}
}

View file

@ -0,0 +1,14 @@
import { PostButtons } from "./components/header/post-buttons"
import { PostTitle } from "./components/header/title"
import styles from "./styles.module.css"
export default function PostLoading() {
return (
<>
<div className={styles.header}>
<PostButtons loading title="" />
<PostTitle title="" loading />
</div>
</>
)
}

View file

@ -1,41 +1,45 @@
import PostPage from "app/(posts)/post/[id]/components/post-page"
import PostPage from "./components/post-page"
import { notFound, redirect } from "next/navigation"
import { getAllPosts, getPostById, Post } from "@lib/server/prisma"
import { getPostById, Post, PostWithFilesAndAuthor } from "@lib/server/prisma"
import { getCurrentUser } from "@lib/server/session"
import ScrollToTop from "@components/scroll-to-top"
import { title } from "process"
import { PostButtons } from "./components/header/post-buttons"
import styles from "./styles.module.css"
import { PostTitle } from "./components/header/title"
import VisibilityControl from "@components/badges/visibility-control"
export type PostProps = {
post: Post
isProtected?: boolean
}
export async function generateStaticParams() {
const posts = await getAllPosts({
where: {
visibility: {
equals: "public"
}
}
})
// export async function generateStaticParams() {
// const posts = await getAllPosts({
// where: {
// visibility: {
// equals: "public"
// }
// }
// })
return posts.map((post) => ({
id: post.id
}))
// return posts.map((post) => ({
// id: post.id
// }))
// }
const fetchOptions = {
withFiles: true,
withAuthor: true
}
const getPost = async (id: string) => {
const post = await getPostById(id, {
withFiles: true,
withAuthor: true
})
const post = (await getPostById(id, fetchOptions)) as PostWithFilesAndAuthor
if (!post) {
return notFound()
}
if (post.visibility === "public") {
return { post }
}
const user = await getCurrentUser()
const isAuthorOrAdmin = user?.id === post?.authorId || user?.role === "admin"
@ -55,7 +59,15 @@ const getPost = async (id: string) => {
return {
post: {
visibility: "protected",
id: post.id
id: post.id,
files: [],
parentId: "",
title: "",
createdAt: new Date("1970-01-01"),
author: {
displayName: ""
},
description: ""
},
isProtected: true,
isAuthor: isAuthorOrAdmin
@ -83,12 +95,40 @@ const PostView = async ({
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}
/>
<>
<div className={styles.header}>
<PostButtons
parentId={post.parentId || undefined}
postId={post.id}
files={post.files}
title={title}
/>
<PostTitle
title={post.title}
createdAt={post.createdAt}
displayName={post.author?.displayName || ""}
visibility={post.visibility}
/>
</div>
{post.description && (
<div>
<p>{post.description}</p>
</div>
)}
<PostPage
isAuthor={isAuthor}
isProtected={isProtected}
post={stringifiedPost}
/>
{isAuthor && (
<span className={styles.controls}>
<VisibilityControl postId={post.id} visibility={post.visibility} />
</span>
)}
<ScrollToTop />
</>
)
}

View file

@ -0,0 +1,17 @@
@media screen and (max-width: 900px) {
.header {
flex-direction: column;
gap: var(--gap);
}
}
.controls {
display: flex;
justify-content: flex-end;
}
@media screen and (max-width: 768px) {
.controls {
justify-content: center;
}
}

View file

@ -0,0 +1,9 @@
.table {
width: 100%;
display: block;
white-space: nowrap;
}
.table thead th {
font-weight: bold;
}

View file

@ -0,0 +1,17 @@
import { getCurrentUser } from "@lib/server/session"
import { redirect } from "next/navigation"
export default async function AdminLayout({
children
}: {
children: React.ReactNode
}) {
const user = await getCurrentUser()
const isAdmin = user?.role === "admin"
if (!isAdmin) {
return redirect("/")
}
return children
}

View file

@ -0,0 +1,13 @@
import { PostTable, UserTable } from "./page"
export default function AdminLoading() {
return (
<div>
<h1>Admin</h1>
<h2>Users</h2>
<UserTable />
<h2>Posts</h2>
<PostTable />
</div>
)
}

96
client/app/admin/page.tsx Normal file
View file

@ -0,0 +1,96 @@
import { getAllPosts, getAllUsers } from "@lib/server/prisma"
import { Spinner } from "@components/spinner"
import styles from "./admin.module.css"
export function UserTable({
users
}: {
users?: Awaited<ReturnType<typeof getAllUsers>>
}) {
return (
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>User ID</th>
</tr>
</thead>
<tbody>
{users?.map((user) => (
<tr key={user.id}>
<td>{user.name ? user.name : "no name"}</td>
<td>{user.email}</td>
<td>{user.role}</td>
<td className={styles.id}>{user.id}</td>
</tr>
))}
{!users && (
<tr>
<td colSpan={4}>
<Spinner />
</td>
</tr>
)}
</tbody>
</table>
)
}
export function PostTable({
posts
}: {
posts?: Awaited<ReturnType<typeof getAllPosts>>
}) {
return (
<table className={styles.table}>
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Created</th>
<th>Visibility</th>
<th className={styles.id}>Post ID</th>
</tr>
</thead>
<tbody>
{posts?.map((post) => (
<tr key={post.id}>
<td><a href={`/post/${post.id}`} target="_blank" rel="noreferrer">{post.title}</a></td>
<td>{"author" in post ? post.author.name : "no author"}</td>
<td>{post.createdAt.toLocaleDateString()}</td>
<td>{post.visibility}</td>
<td>{post.id}</td>
</tr>
))}
{!posts && (
<tr>
<td colSpan={5}>
<Spinner />
</td>
</tr>
)}
</tbody>
</table>
)
}
export default async function AdminPage() {
const usersPromise = getAllUsers()
const postsPromise = getAllPosts({
withAuthor: true
})
const [users, posts] = await Promise.all([usersPromise, postsPromise])
return (
<div className={styles.wrapper}>
<h1>Admin</h1>
<h2>Users</h2>
<UserTable users={users} />
<h2>Posts</h2>
<PostTable posts={posts} />
</div>
)
}

View file

@ -1,5 +1,5 @@
'use client'
// import Tooltip from "@components/tooltip"
import Tooltip from "@components/tooltip"
import { timeAgo } from "@lib/time-ago"
import { useMemo, useState, useEffect } from "react"
import Badge from "../badge"
@ -15,15 +15,15 @@ const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
return () => clearInterval(interval)
}, [createdDate])
// const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
return (
// TODO: investigate tooltip
// <Tooltip content={formattedTime}>
// TODO: investigate tooltip not showing
<Tooltip content={formattedTime}>
<Badge type="secondary">
{" "}
<>{time}</>
</Badge>
// </Tooltip>
</Tooltip>
)
}

View file

@ -1,3 +1,5 @@
"use client"
import Tooltip from "@components/tooltip"
import { timeUntil } from "@lib/time-ago"
import { useEffect, useMemo, useState } from "react"
@ -5,8 +7,7 @@ import Badge from "../badge"
const ExpirationBadge = ({
postExpirationDate
}: // onExpires
{
}: {
postExpirationDate: Date | string | null
onExpires?: () => void
}) => {
@ -39,15 +40,6 @@ const ExpirationBadge = ({
return timeUntilString === "in 0 seconds"
}, [timeUntilString])
// useEffect(() => {
// // check if expired every
// if (isExpired) {
// if (onExpires) {
// onExpires();
// }
// }
// }, [isExpired, onExpires])
if (!expirationDate) {
return null
}

View file

@ -1,3 +1,5 @@
'use client';
import PasswordModal from "@components/password-modal"
import { useCallback, useState } from "react"
import ButtonGroup from "@components/button-group"
@ -8,10 +10,11 @@ import { Spinner } from "@components/spinner"
type Props = {
postId: string
visibility: string
setVisibility: (visibility: string) => void
}
const VisibilityControl = ({ postId, visibility, setVisibility }: Props) => {
const VisibilityControl = ({ postId, visibility: postVisibility }: Props) => {
const [visibility, setVisibility] = useState<string>(postVisibility)
const [isSubmitting, setSubmitting] = useState<string | null>()
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const { setToast } = useToasts()

View file

@ -36,6 +36,16 @@
width: min-content;
}
.header {
transition: opacity 0.2s ease-in-out;
opacity: 0;
}
.header:not(.loading) {
opacity: 1;
}
.selectContent {
width: auto;
height: 18px;

View file

@ -34,8 +34,10 @@ type Tab = {
const Header = () => {
const session = useSession()
const signedIn = session?.status === "authenticated"
const isSignedIn = session?.status === "authenticated"
const isAdmin = session?.data?.user?.role === "admin"
const isLoading = session?.status === "loading"
const pathname = usePathname()
const { setTheme, resolvedTheme } = useTheme()
@ -98,7 +100,7 @@ const Header = () => {
value: "theme"
})
if (signedIn)
if (isSignedIn)
return [
{
name: "New",
@ -151,7 +153,7 @@ const Header = () => {
href: "/signup"
}
]
}, [isAdmin, resolvedTheme, signedIn, setTheme])
}, [isAdmin, resolvedTheme, isSignedIn, setTheme])
// // TODO: this should not be necessary.
// if (!clientHydrated) {
@ -165,7 +167,9 @@ const Header = () => {
const buttons = pages.map(getButton)
return (
<header>
<header className={clsx(styles.header, {
[styles.loading]: isLoading,
})}>
<div className={styles.tabs}>
<div className={styles.buttons}>{buttons}</div>
</div>

View file

@ -45,7 +45,6 @@ const PasswordModal = ({
if (!open) onClose()
}}
>
{/* <Dialog.Trigger asChild>Enter a password</Dialog.Trigger> */}
<Dialog.Portal>
<Dialog.Overlay className={styles.overlay} />
<Dialog.Content
@ -85,10 +84,8 @@ const PasswordModal = ({
</Note>
)}
</fieldset>
<Dialog.Close className={styles.close}>
<Button onClick={onSubmit}>Submit</Button>
<Button>Cancel</Button>
</Dialog.Close>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onSubmit}>Submit</Button>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View file

@ -45,6 +45,7 @@
display: flex;
justify-content: flex-end;
width: 100%;
gap: var(--gap-half);
}
@keyframes overlayShow {

View file

@ -1,3 +1,5 @@
'use client';
import Button from "@components/button"
import Tooltip from "@components/tooltip"
import { useEffect, useState } from "react"

View file

@ -1,6 +1,5 @@
import "@styles/globals.css"
import { LayoutWrapper } from "./root-layout-wrapper"
import { getSession } from "@lib/server/session"
import { ServerThemeProvider } from "@wits/next-themes"
interface RootLayoutProps {

View file

@ -1,8 +1,13 @@
import { redirect } from "next/navigation"
import { getPostsByUser } from "@lib/server/prisma"
import { getPostsByUser, User } from "@lib/server/prisma"
import PostList from "@components/post-list"
import { getCurrentUser } from "@lib/server/session"
import { authOptions } from "@lib/server/auth"
import { cache } from "react"
const cachedGetPostsByUser = cache(
async (userId: User["id"]) => await getPostsByUser(userId, true)
)
export default async function Mine() {
const userId = (await getCurrentUser())?.id
@ -11,7 +16,7 @@ export default async function Mine() {
return redirect(authOptions.pages?.signIn || "/new")
}
const posts = await getPostsByUser(userId, true)
const posts = await cachedGetPostsByUser(userId)
const hasMore = false
const stringifiedPosts = JSON.stringify(posts)

View file

@ -78,11 +78,6 @@
box-sizing: border-box;
}
// TODO: replace this with an accessible alternative
*:focus-visible {
outline: none !important;
}
::selection {
text-shadow: none;
background: var(--fg) !important;

View file

@ -146,7 +146,7 @@ type GetPostByIdOptions = {
export const getPostById = async (
postId: Post["id"],
options?: GetPostByIdOptions
) => {
): Promise<Post | PostWithFiles | PostWithFilesAndAuthor | null> => {
const post = await prisma.post.findUnique({
where: {
id: postId
@ -164,26 +164,35 @@ export const getPostById = async (
}
})
return post as PostWithFiles
return post
}
export const getAllPosts = async ({
withFiles = false,
withAuthor = false,
take = 100,
...rest
}: {
withFiles?: boolean
} & Prisma.PostFindManyArgs = {}) => {
withAuthor?: boolean
} & Prisma.PostFindManyArgs = {}): Promise<
Post[] | PostWithFiles[] | PostWithFilesAndAuthor[]
> => {
const posts = await prisma.post.findMany({
include: {
files: withFiles
files: withFiles,
author: withAuthor
},
// TODO: optimize which to grab
take,
...rest
})
return posts as PostWithFiles[]
return posts as typeof withFiles extends true
? typeof withAuthor extends true
? PostWithFilesAndAuthor[]
: PostWithFiles[]
: Post[]
}
export type UserWithPosts = User & {

View file

@ -8,29 +8,7 @@ const nextConfig = {
// esmExternals: true,
appDir: true
},
webpack: (config, { dev, isServer }) => {
if (!dev && !isServer) {
// TODO: enabling Preact causes the file switcher to hang the browser process
// Object.assign(config.resolve.alias, {
// react: "preact/compat",
// "react-dom/test-utils": "preact/test-utils",
// "react-dom": "preact/compat"
// })
}
return config
},
async rewrites() {
return [
{
source: "/file/raw/:id",
destination: `/api/file/raw/:id`
},
{
source: "/home",
destination: "/",
}
]
}
}
export default bundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(

View file

@ -22,12 +22,12 @@
"@radix-ui/react-tabs": "^1.0.1",
"@radix-ui/react-tooltip": "^1.0.2",
"@wcj/markdown-to-html": "^2.1.2",
"@wits/next-themes": "^0.2.14",
"@wits/next-themes": "0.2.12",
"bcrypt": "^5.1.0",
"client-zip": "2.2.1",
"jest": "^29.3.1",
"next": "13.0.6-canary.4",
"next-auth": "^4.17.0",
"next": "13.0.7-canary.1",
"next-auth": "^4.18.0",
"prisma": "^4.6.1",
"react": "18.2.0",
"react-datepicker": "4.8.0",
@ -35,7 +35,7 @@
"react-dropzone": "14.2.3",
"react-feather": "^2.0.10",
"react-hot-toast": "^2.4.0",
"textarea-markdown-editor": "0.1.13",
"textarea-markdown-editor": "1.0.4",
"ts-jest": "^29.0.3"
},
"devDependencies": {
@ -47,6 +47,7 @@
"@types/react-dom": "18.0.3",
"clsx": "^1.2.1",
"cross-env": "7.0.3",
"csstype": "^3.1.1",
"eslint": "8.27.0",
"eslint-config-next": "13.0.3",
"next-unused": "0.0.6",

View file

@ -1,5 +1,3 @@
// ts nextjs api handler
import { withMethods } from "@lib/api-middleware/with-methods"
import { parseQueryParam } from "@lib/server/parse-query-param"
import { NextApiRequest, NextApiResponse } from "next"

View file

@ -16,16 +16,17 @@ specifiers:
'@types/react-datepicker': 4.4.1
'@types/react-dom': 18.0.3
'@wcj/markdown-to-html': ^2.1.2
'@wits/next-themes': ^0.2.14
'@wits/next-themes': 0.2.12
bcrypt: ^5.1.0
client-zip: 2.2.1
clsx: ^1.2.1
cross-env: 7.0.3
csstype: ^3.1.1
eslint: 8.27.0
eslint-config-next: 13.0.3
jest: ^29.3.1
next: 13.0.6-canary.4
next-auth: ^4.17.0
next: 13.0.7-canary.1
next-auth: ^4.18.0
next-unused: 0.0.6
prettier: 2.6.2
prisma: ^4.6.1
@ -36,13 +37,13 @@ specifiers:
react-feather: ^2.0.10
react-hot-toast: ^2.4.0
sharp: ^0.31.2
textarea-markdown-editor: 0.1.13
textarea-markdown-editor: 1.0.4
ts-jest: ^29.0.3
typescript: 4.6.4
typescript-plugin-css-modules: 3.4.0
dependencies:
'@next-auth/prisma-adapter': 1.0.5_o53gfpk3vz2btjrokqfjjwwn3m
'@next-auth/prisma-adapter': 1.0.5_qwexivae5olc6wqfcmxswm7qjy
'@next/eslint-plugin-next': 13.0.5-canary.3
'@prisma/client': 4.6.1_prisma@4.6.1
'@radix-ui/react-dialog': 1.0.2_jbvntnid6ohjelon6ccj5dhg2u
@ -51,20 +52,20 @@ dependencies:
'@radix-ui/react-tabs': 1.0.1_biqbaboplfbrettd7655fr4n2y
'@radix-ui/react-tooltip': 1.0.2_jbvntnid6ohjelon6ccj5dhg2u
'@wcj/markdown-to-html': 2.1.2
'@wits/next-themes': 0.2.14_wq5w3t2dm6pp5l2gadmp4osgce
'@wits/next-themes': 0.2.12_ihvxcpofhpc4k2aqfys2drrlkq
bcrypt: 5.1.0
client-zip: 2.2.1
jest: 29.3.1_@types+node@17.0.23
next: 13.0.6-canary.4_biqbaboplfbrettd7655fr4n2y
next-auth: 4.17.0_wq5w3t2dm6pp5l2gadmp4osgce
next: 13.0.7-canary.1_biqbaboplfbrettd7655fr4n2y
next-auth: 4.18.0_ihvxcpofhpc4k2aqfys2drrlkq
prisma: 4.6.1
react: 18.2.0
react-datepicker: 4.8.0_biqbaboplfbrettd7655fr4n2y
react-dom: 18.2.0_react@18.2.0
react-dropzone: 14.2.3_react@18.2.0
react-feather: 2.0.10_react@18.2.0
react-hot-toast: 2.4.0_biqbaboplfbrettd7655fr4n2y
textarea-markdown-editor: 0.1.13_biqbaboplfbrettd7655fr4n2y
react-hot-toast: 2.4.0_owo25xnefcwdq3zjgtohz6dbju
textarea-markdown-editor: 1.0.4_biqbaboplfbrettd7655fr4n2y
ts-jest: 29.0.3_7hcmezpa7bajbjecov7p46z4aa
optionalDependencies:
@ -79,6 +80,7 @@ devDependencies:
'@types/react-dom': 18.0.3
clsx: 1.2.1
cross-env: 7.0.3
csstype: 3.1.1
eslint: 8.27.0
eslint-config-next: 13.0.3_hsmo2rtalirsvadpuxki35bq2i
next-unused: 0.0.6
@ -783,14 +785,14 @@ packages:
- supports-color
dev: false
/@next-auth/prisma-adapter/1.0.5_o53gfpk3vz2btjrokqfjjwwn3m:
/@next-auth/prisma-adapter/1.0.5_qwexivae5olc6wqfcmxswm7qjy:
resolution: {integrity: sha512-VqMS11IxPXrPGXw6Oul6jcyS/n8GLOWzRMrPr3EMdtD6eOalM6zz05j08PcNiis8QzkfuYnCv49OvufTuaEwYQ==}
peerDependencies:
'@prisma/client': '>=2.26.0 || >=3'
next-auth: ^4
dependencies:
'@prisma/client': 4.6.1_prisma@4.6.1
next-auth: 4.17.0_wq5w3t2dm6pp5l2gadmp4osgce
next-auth: 4.18.0_ihvxcpofhpc4k2aqfys2drrlkq
dev: false
/@next/bundle-analyzer/12.1.6:
@ -802,8 +804,8 @@ packages:
- utf-8-validate
dev: true
/@next/env/13.0.6-canary.4:
resolution: {integrity: sha512-3SMpcpiHIPlUBdWOyf130lgiOZ56l1Jhhq1zbZbLa42No2czgLSo5R9X3dy21ozPNIzDMj7K5FwhhkoO0CQP+Q==}
/@next/env/13.0.7-canary.1:
resolution: {integrity: sha512-pjFCstWLbHpO3wAI4H6Jueiqb9s1IB++w8e79RJvry5K2ElzRpUPXeTkjI/oLSZ6W9DDG8DTOMmJtat2o+h3jA==}
dev: false
/@next/eslint-plugin-next/13.0.3:
@ -818,8 +820,8 @@ packages:
glob: 7.1.7
dev: false
/@next/swc-android-arm-eabi/13.0.6-canary.4:
resolution: {integrity: sha512-FfjHzjjiJPHHwEGpgj8dO3Js3CMoO/emVb6XrzutZjbDI5kMvOLEFRiCKsAvcy5Do7nb1tkj1dc4wHE/ZgVGsQ==}
/@next/swc-android-arm-eabi/13.0.7-canary.1:
resolution: {integrity: sha512-bmUIfXap+EwEpkWqGso3fMScXpbbUHecFByjnnmWOXU21e1bhE7UfCDtXzEn3utwt8MlUwA/h/5CGf6wMFUU8w==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
@ -827,8 +829,8 @@ packages:
dev: false
optional: true
/@next/swc-android-arm64/13.0.6-canary.4:
resolution: {integrity: sha512-r01HaiYopunrkG6PiNljTkVulnmhYukJKQjOd+EsCRAq4aTN5geTQq4l1ZdaSJTVGeMmxa7SbtXQgZxH8KmhQQ==}
/@next/swc-android-arm64/13.0.7-canary.1:
resolution: {integrity: sha512-k0Wo/NgoAj1Bcp7X7fYc8C4G4Y+qiLrjqWGTQ38Cx5NHJfMJf6gUTfgc2OTBG96tKj21LwKhhg6BEqV9mRuzOg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
@ -836,8 +838,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-arm64/13.0.6-canary.4:
resolution: {integrity: sha512-sGBYihzRUIIlGfi70L9uWNRFgM1+d9KDa+tDp/goCBmzty6KExxet3DT2drOnGlGOKPV/+fooQCEAV6cqkMVkg==}
/@next/swc-darwin-arm64/13.0.7-canary.1:
resolution: {integrity: sha512-phrROUvPXYouNl4Bs7kRmkTcU18V2gxIbwiWonQYWROfCQJckELHM0MFOAfLbkJYRT/vcyp/o2bgiPkWv/fP8A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@ -845,8 +847,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-x64/13.0.6-canary.4:
resolution: {integrity: sha512-KhkVYdLH288jje1cA8IZIvwPHhiuXsmXcDhUHNg437phhL+Vn52UD2WjvkPDAJCNgpsM23hTTHbxtHELjlXcNw==}
/@next/swc-darwin-x64/13.0.7-canary.1:
resolution: {integrity: sha512-ERpeI2zWlTj4xKdhwq8h9gyMWHSCh5UNm3ekX/MCgq1Mg1cLsv/kINeVQuvVP5II5HSHoEjnw2GvAMB4ayhcUA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@ -854,8 +856,8 @@ packages:
dev: false
optional: true
/@next/swc-freebsd-x64/13.0.6-canary.4:
resolution: {integrity: sha512-rOs64ugbLQd8pPjrVpaGZVZZ5xgn/plFNLdjFdldBS3E0/fwZZWBZgKsPf9ItrNgXPO4PKRk/KmoXBwZwGS3Ng==}
/@next/swc-freebsd-x64/13.0.7-canary.1:
resolution: {integrity: sha512-L/YIIzaoV58UHLgiR8jfr0V9HXmUvHf1a2+1esSsTlMXZ0Y3SzcczuLgEu0/AYKEgHcfl+vcng9FBeqXtVlYyQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
@ -863,8 +865,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm-gnueabihf/13.0.6-canary.4:
resolution: {integrity: sha512-isuWwAJhXRJSlv0RCmrMdtlShGaDygQlPD0T6MzEdAG79b216FwZDu3yWzkny+bhxx/5E6w2SiJfjS9nuICLxw==}
/@next/swc-linux-arm-gnueabihf/13.0.7-canary.1:
resolution: {integrity: sha512-KVB7lAgtUgqrroqozYSCZIwVQITHhjbe99n/C6A9BYIAUtwITrLIn8Sj7D0a0sEhdDL8Y/rzXZGWMqL7f1Hg3A==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
@ -872,8 +874,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-gnu/13.0.6-canary.4:
resolution: {integrity: sha512-LYfHVdHjadJ3a+MiFhjhM6ou5Gx1hlGPN2xPshMTPY/KvESofPiBg1m3bJhjNaaNheSmQDJJ1QhVWM+EMBEZNA==}
/@next/swc-linux-arm64-gnu/13.0.7-canary.1:
resolution: {integrity: sha512-tA4yYk1+2fPgs0q6r94d7sKQosf9jZGTMXIS0yOykk246L3+npsDqyBrdCusaJv9q3Fm5S8lfwp4vqoLNtcFLg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -881,8 +883,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-musl/13.0.6-canary.4:
resolution: {integrity: sha512-Kvqw+9p0/amAHN7Q3LoZ0GJbyBYHUJm5C23ojihsz0UHemO057q+MwgwZbmz6ufwmOrUCfoq3RQo4C5vUzeNCw==}
/@next/swc-linux-arm64-musl/13.0.7-canary.1:
resolution: {integrity: sha512-KKN/nd2g2Cixs+av1mLeiNvhm+8T8ZiuzZHRrA4h4OWwreI+weS0iXBa1sBGvNp863MxE1mxKOv2xFhSbWK/CQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -890,8 +892,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-gnu/13.0.6-canary.4:
resolution: {integrity: sha512-LisCGX5oUXPrc43tyc/rsv3rKI2Yxqd5Eq7LsXI3TWIjH5xLeRi1LWxGxEc3asAI28dKBjgizyUzGbPwBE2KdQ==}
/@next/swc-linux-x64-gnu/13.0.7-canary.1:
resolution: {integrity: sha512-1crxMrvO2pHmsDxSkVknchiyLHYpkKKkwhnrFYKP06bZSEONAry6VTYJ6l73PK9mp1kzFAtke5k9yG4LG0fbAQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -899,8 +901,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-musl/13.0.6-canary.4:
resolution: {integrity: sha512-rXRilFn8Tqop6G7c7CluIgXGhyr1k4aqEJBir4mlHWdtcW3JMolWZCJ/agXSQ7+JyS1LROI93XN0LKRu9vapIw==}
/@next/swc-linux-x64-musl/13.0.7-canary.1:
resolution: {integrity: sha512-1incysWrn+PEK6XRE1QnK2UI7//N6gfmeaFC1KIlRyt0JmyF8U3V+I6Qcar9nHz9hY9e8yszFQY0A9X0jsfkUQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -908,8 +910,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-arm64-msvc/13.0.6-canary.4:
resolution: {integrity: sha512-LVlG39FDY/VOhRs5gKvSDUjgPpdWyDyGQjROFP+WGxGgKd+NZU7dk4sXkAo71jOZbmqT+EcGI69dJMK5gDO12Q==}
/@next/swc-win32-arm64-msvc/13.0.7-canary.1:
resolution: {integrity: sha512-AE5NYCeXilQnzIOma7y3cNcYVQsHJsEZ3r4/DTKvmFvuFVBkxza7Uxzi5rwD67ewSbOzir1xr+LBtI6vCmQ/Fw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@ -917,8 +919,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-ia32-msvc/13.0.6-canary.4:
resolution: {integrity: sha512-XWpmQngO9Z5kTnUum4dnXg1jOP2H36qOEofvNj87BtlUJFxAbMzrs9Zbnix+1CmGbBg88mNTuaH1LAYupc+utQ==}
/@next/swc-win32-ia32-msvc/13.0.7-canary.1:
resolution: {integrity: sha512-kG84cAm/FZsK3u2vgcUpQRT28NEA+vMTMrp4ufdHPu+c0o0aEcLqh3yQstWqw+hGpYQxiB0EF95K9bbRfHkgOQ==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
@ -926,8 +928,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-x64-msvc/13.0.6-canary.4:
resolution: {integrity: sha512-E6FnI9m40oQAYgcwsY7i9HLHSCo0Lh6m+TzXUEdMkjl7WVpo96dtvEVt4/8wDnVPA+x/PkM6Qo8MKooZmCeeZw==}
/@next/swc-win32-x64-msvc/13.0.7-canary.1:
resolution: {integrity: sha512-FrEMvjaPJ3g2BcQp0aovr4Jj5L/KnvWlnvw5fIPMMoDmUYuMkbR4ZbAvIrOaLGCRiO0862kcoCcdhZ75AwzU2g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -1709,14 +1711,14 @@ packages:
- supports-color
dev: false
/@wits/next-themes/0.2.14_wq5w3t2dm6pp5l2gadmp4osgce:
resolution: {integrity: sha512-fHKb/tRcWbYNblGHZtfvAQztDhzUB9d7ZkYOny0BisSPh6EABcsqxKB48ABUQztcmKywlp2zEMkLcSRj/PQBSw==}
/@wits/next-themes/0.2.12_ihvxcpofhpc4k2aqfys2drrlkq:
resolution: {integrity: sha512-P/qtLW68n4xBLT8UfLkCD/0jmF0yWxdf3xpGCDbfR6WuvK2brJDQ0DhPbhsucGkJ42ArA6ItKqcIo7/cnKzhGg==}
peerDependencies:
next: '*'
react: '*'
react-dom: '*'
dependencies:
next: 13.0.6-canary.4_biqbaboplfbrettd7655fr4n2y
next: 13.0.7-canary.1_biqbaboplfbrettd7655fr4n2y
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
dev: false
@ -3423,10 +3425,12 @@ packages:
minimist: 1.2.7
dev: true
/goober/2.1.11:
/goober/2.1.11_csstype@3.1.1:
resolution: {integrity: sha512-5SS2lmxbhqH0u9ABEWq7WPU69a4i2pYcHeCxqaNq6Cw3mnrF0ghWNM4tEGid4dKy8XNIAUbuThuozDHHKJVh3A==}
peerDependencies:
csstype: ^3.0.10
dependencies:
csstype: 3.1.1
dev: false
/graceful-fs/4.2.10:
@ -5198,8 +5202,8 @@ packages:
dev: true
optional: true
/next-auth/4.17.0_wq5w3t2dm6pp5l2gadmp4osgce:
resolution: {integrity: sha512-aN2tdnjS0MDeUpB2tBDOaWnegkgeMWrsccujbXRGMJ607b+EwRcy63MFGSr0OAboDJEe0902piXQkt94GqF8Qw==}
/next-auth/4.18.0_ihvxcpofhpc4k2aqfys2drrlkq:
resolution: {integrity: sha512-lqJtusYqUwDiwzO4+B+lx/vKCuf/akcdhxT5R47JmS5gvI9O6Y4CZYc8coysY7XaMGHCxfttvTSEw76RA8gNTg==}
engines: {node: ^12.19.0 || ^14.15.0 || ^16.13.0 || ^18.12.0}
peerDependencies:
next: ^12.2.5 || ^13
@ -5214,7 +5218,7 @@ packages:
'@panva/hkdf': 1.0.2
cookie: 0.5.0
jose: 4.11.0
next: 13.0.6-canary.4_biqbaboplfbrettd7655fr4n2y
next: 13.0.7-canary.1_biqbaboplfbrettd7655fr4n2y
oauth: 0.9.15
openid-client: 5.3.0
preact: 10.11.2
@ -5235,8 +5239,8 @@ packages:
- supports-color
dev: true
/next/13.0.6-canary.4_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-41koM6+K/ywM2N5beni1x/vP2huDVTBbHikP46eLAmJK7KU+CHwoyblWDjqeg0c6PAkoAHybk5LjYbfjiFiBCw==}
/next/13.0.7-canary.1_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-xfLT5Ikty2zcFCYSsYaQta3Dik09BJmwwj5a3i/ceh+51rJ+I3lP9+BbB9dUCUmgftOgxyyFUkzIZJ/gi3fUiQ==}
engines: {node: '>=14.6.0'}
hasBin: true
peerDependencies:
@ -5253,7 +5257,7 @@ packages:
sass:
optional: true
dependencies:
'@next/env': 13.0.6-canary.4
'@next/env': 13.0.7-canary.1
'@swc/helpers': 0.4.14
caniuse-lite: 1.0.30001431
postcss: 8.4.14
@ -5261,19 +5265,19 @@ packages:
react-dom: 18.2.0_react@18.2.0
styled-jsx: 5.1.0_react@18.2.0
optionalDependencies:
'@next/swc-android-arm-eabi': 13.0.6-canary.4
'@next/swc-android-arm64': 13.0.6-canary.4
'@next/swc-darwin-arm64': 13.0.6-canary.4
'@next/swc-darwin-x64': 13.0.6-canary.4
'@next/swc-freebsd-x64': 13.0.6-canary.4
'@next/swc-linux-arm-gnueabihf': 13.0.6-canary.4
'@next/swc-linux-arm64-gnu': 13.0.6-canary.4
'@next/swc-linux-arm64-musl': 13.0.6-canary.4
'@next/swc-linux-x64-gnu': 13.0.6-canary.4
'@next/swc-linux-x64-musl': 13.0.6-canary.4
'@next/swc-win32-arm64-msvc': 13.0.6-canary.4
'@next/swc-win32-ia32-msvc': 13.0.6-canary.4
'@next/swc-win32-x64-msvc': 13.0.6-canary.4
'@next/swc-android-arm-eabi': 13.0.7-canary.1
'@next/swc-android-arm64': 13.0.7-canary.1
'@next/swc-darwin-arm64': 13.0.7-canary.1
'@next/swc-darwin-x64': 13.0.7-canary.1
'@next/swc-freebsd-x64': 13.0.7-canary.1
'@next/swc-linux-arm-gnueabihf': 13.0.7-canary.1
'@next/swc-linux-arm64-gnu': 13.0.7-canary.1
'@next/swc-linux-arm64-musl': 13.0.7-canary.1
'@next/swc-linux-x64-gnu': 13.0.7-canary.1
'@next/swc-linux-x64-musl': 13.0.7-canary.1
'@next/swc-win32-arm64-msvc': 13.0.7-canary.1
'@next/swc-win32-ia32-msvc': 13.0.7-canary.1
'@next/swc-win32-x64-msvc': 13.0.7-canary.1
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@ -5926,14 +5930,14 @@ packages:
react: 18.2.0
dev: false
/react-hot-toast/2.4.0_biqbaboplfbrettd7655fr4n2y:
/react-hot-toast/2.4.0_owo25xnefcwdq3zjgtohz6dbju:
resolution: {integrity: sha512-qnnVbXropKuwUpriVVosgo8QrB+IaPJCpL8oBI6Ov84uvHZ5QQcTp2qg6ku2wNfgJl6rlQXJIQU5q+5lmPOutA==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
react-dom: '>=16'
dependencies:
goober: 2.1.11
goober: 2.1.11_csstype@3.1.1
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
transitivePeerDependencies:
@ -6022,10 +6026,6 @@ packages:
tslib: 2.4.1
dev: false
/react-trigger-change/1.0.2:
resolution: {integrity: sha512-3x2i/CTQZZlzTuDIfOrJ12r1IqcNWLxHVHXKTtlb4L0slt6Zy3YF3smimx4kdX4X/ASSuQnI/n1q4cTqDwWkPA==}
dev: false
/react/18.2.0:
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'}
@ -6732,16 +6732,15 @@ packages:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
/textarea-markdown-editor/0.1.13_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-2r1gTPFA/wwAzt+Aa6LVZWjJNvL0aXfR6Z9T6eQBpJ1AK6gtPVCZgkO97KIrqpAmMcwgNCz0ToYj2AqPufdVeg==}
/textarea-markdown-editor/1.0.4_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-4uA8EZ0FkIL0dq89+xiA0BEo832/rKdtoi2T4Wab0wLZfHys82JE1i5YJf8BKAr/IQELF2NxQ5LITYkb8BGIFA==}
peerDependencies:
react: ^16.9.0 || ^17.0
react-dom: ^16.9.0 || ^17.0
react: ^16.8.0 || ^17.0.0 || || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
mousetrap: 1.6.5
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
react-trigger-change: 1.0.2
dev: false
/tmpl/1.0.5: