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