Bug fixes, code cleanup, made root dir /

This commit is contained in:
Max Leiter 2023-01-07 13:02:52 -08:00
parent c21ca52a59
commit d9e7aa5ecf
78 changed files with 394 additions and 352 deletions

35
.gitignore vendored
View file

@ -1,2 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# production env
.env
# vercel
.vercel .vercel
drift.sqlite
# typescript
*.tsbuildinfo

View file

@ -1,5 +1,5 @@
{ {
"typescript.tsdk": "src/node_modules/.pnpm/typescript@4.6.4/node_modules/typescript/lib", "typescript.tsdk": "node_modules/.pnpm/typescript@4.6.4/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true, "typescript.enablePromptUseWorkspaceTsdk": true,
"dotenv.enableAutocloaking": false "dotenv.enableAutocloaking": false
} }

View file

View file

@ -23,7 +23,6 @@
"@radix-ui/react-tabs": "^1.0.1", "@radix-ui/react-tabs": "^1.0.1",
"@radix-ui/react-tooltip": "^1.0.2", "@radix-ui/react-tooltip": "^1.0.2",
"@wcj/markdown-to-html": "^2.1.2", "@wcj/markdown-to-html": "^2.1.2",
"@wits/next-themes": "0.2.14",
"client-only": "^0.0.1", "client-only": "^0.0.1",
"client-zip": "2.2.1", "client-zip": "2.2.1",
"jest": "^29.3.1", "jest": "^29.3.1",
@ -71,7 +70,7 @@
"next-unused": { "next-unused": {
"alias": { "alias": {
"@components": "components/", "@components": "components/",
"@lib": "lib/", "@lib": "src/lib/",
"@styles": "styles/" "@styles": "styles/"
}, },
"include": [ "include": [
@ -79,6 +78,9 @@
"lib" "lib"
] ]
}, },
"prisma": {
"schema": "src/prisma/schema.prisma"
},
"overrides": { "overrides": {
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0" "react-dom": "18.2.0"

View file

@ -21,7 +21,6 @@ specifiers:
'@typescript-eslint/eslint-plugin': ^5.46.1 '@typescript-eslint/eslint-plugin': ^5.46.1
'@typescript-eslint/parser': ^5.46.1 '@typescript-eslint/parser': ^5.46.1
'@wcj/markdown-to-html': ^2.1.2 '@wcj/markdown-to-html': ^2.1.2
'@wits/next-themes': 0.2.14
client-only: ^0.0.1 client-only: ^0.0.1
client-zip: 2.2.1 client-zip: 2.2.1
clsx: ^1.2.1 clsx: ^1.2.1
@ -63,7 +62,6 @@ dependencies:
'@radix-ui/react-tabs': 1.0.1_biqbaboplfbrettd7655fr4n2y '@radix-ui/react-tabs': 1.0.1_biqbaboplfbrettd7655fr4n2y
'@radix-ui/react-tooltip': 1.0.2_jbvntnid6ohjelon6ccj5dhg2u '@radix-ui/react-tooltip': 1.0.2_jbvntnid6ohjelon6ccj5dhg2u
'@wcj/markdown-to-html': 2.1.2 '@wcj/markdown-to-html': 2.1.2
'@wits/next-themes': 0.2.14_3tcywg5dy5qhcmsv6sy2pt6lua
client-only: 0.0.1 client-only: 0.0.1
client-zip: 2.2.1 client-zip: 2.2.1
jest: 29.3.1_@types+node@17.0.23 jest: 29.3.1_@types+node@17.0.23
@ -1805,18 +1803,6 @@ packages:
- supports-color - supports-color
dev: false dev: false
/@wits/next-themes/0.2.14_3tcywg5dy5qhcmsv6sy2pt6lua:
resolution: {integrity: sha512-fHKb/tRcWbYNblGHZtfvAQztDhzUB9d7ZkYOny0BisSPh6EABcsqxKB48ABUQztcmKywlp2zEMkLcSRj/PQBSw==}
peerDependencies:
next: '*'
react: '*'
react-dom: '*'
dependencies:
next: 13.1.2-canary.0_biqbaboplfbrettd7655fr4n2y
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
dev: false
/acorn-jsx/5.3.2_acorn@8.8.1: /acorn-jsx/5.3.2_acorn@8.8.1:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

Before

Width:  |  Height:  |  Size: 653 B

After

Width:  |  Height:  |  Size: 653 B

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

Before

Width:  |  Height:  |  Size: 930 B

After

Width:  |  Height:  |  Size: 930 B

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

35
src/.gitignore vendored
View file

@ -1,35 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# production env
.env
# vercel
.vercel
# typescript
*.tsbuildinfo

View file

@ -1,4 +0,0 @@
{
"typescript.tsdk": "node_modules/.pnpm/typescript@4.6.4/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

View file

@ -1,34 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View file

@ -1,5 +1,5 @@
import Auth from "../components" import Auth from "../components"
import { getRequiresPasscode } from "pages/api/auth/requires-passcode" import { getRequiresPasscode } from "src/pages/api/auth/requires-passcode"
import config from "@lib/config" import config from "@lib/config"
const getPasscode = async () => { const getPasscode = async () => {

View file

@ -1,7 +1,7 @@
import { Popover } from "@components/popover" import { Popover } from "@components/popover"
import { codeFileExtensions } from "@lib/constants" import { codeFileExtensions } from "@lib/constants"
import clsx from "clsx" import clsx from "clsx"
import type { PostWithFiles } from "lib/server/prisma" import type { PostWithFiles } from "src/lib/server/prisma"
import styles from "./dropdown.module.css" import styles from "./dropdown.module.css"
import buttonStyles from "@components/button/button.module.css" import buttonStyles from "@components/button/button.module.css"
import { ChevronDown, Code, File as FileIcon } from "react-feather" import { ChevronDown, Code, File as FileIcon } from "react-feather"

View file

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

View file

@ -11,7 +11,6 @@ import styles from "./formatting-icons.module.css"
import { TextareaMarkdownRef } from "textarea-markdown-editor" import { TextareaMarkdownRef } from "textarea-markdown-editor"
import Tooltip from "@components/tooltip" import Tooltip from "@components/tooltip"
import Button from "@components/button" import Button from "@components/button"
import ButtonGroup from "@components/button-group"
import clsx from "clsx" import clsx from "clsx"
// TODO: clean up // TODO: clean up

View file

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

View file

@ -1,4 +1,4 @@
import NewPost from "app/(posts)/new/components/new" import NewPost from "src/app/(posts)/new/components/new"
import "./react-datepicker.css" import "./react-datepicker.css"
const New = () => <NewPost /> const New = () => <NewPost />

View file

@ -2,7 +2,7 @@
import Button from "@components/button" import Button from "@components/button"
import ButtonGroup from "@components/button-group" import ButtonGroup from "@components/button-group"
import FileDropdown from "app/(posts)/components/file-dropdown" import FileDropdown from "src/app/(posts)/components/file-dropdown"
import { Edit, ArrowUpCircle, Archive } from "react-feather" import { Edit, ArrowUpCircle, Archive } from "react-feather"
import styles from "./post-buttons.module.css" import styles from "./post-buttons.module.css"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"

View file

@ -22,7 +22,7 @@ export const PostTitle = ({
createdAt, createdAt,
expiresAt, expiresAt,
loading, loading,
authorId // authorId
}: TitleProps) => { }: TitleProps) => {
return ( return (
<span className={styles.title}> <span className={styles.title}>
@ -33,10 +33,9 @@ export const PostTitle = ({
> >
{title}{" "} {title}{" "}
<span style={{ color: "var(--gray)" }}> <span style={{ color: "var(--gray)" }}>
by{" "} by {/* <Link colored href={`/author/${authorId}`}> */}
<Link colored href={`/author/${authorId}`}>
{displayName || "anonymous"} {displayName || "anonymous"}
</Link> {/* </Link> */}
</span> </span>
</h1> </h1>
{!loading && ( {!loading && (

View file

@ -3,6 +3,7 @@
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--gap);
} }
.title .badges { .title .badges {
@ -11,12 +12,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.title h3 {
margin: 0;
padding: 0;
display: inline-block;
}
.titleWithDropdown { .titleWithDropdown {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View file

@ -4,7 +4,7 @@ import Button from "@components/button"
import ButtonGroup from "@components/button-group" import ButtonGroup from "@components/button-group"
import Skeleton from "@components/skeleton" import Skeleton from "@components/skeleton"
import Tooltip from "@components/tooltip" import Tooltip from "@components/tooltip"
import DocumentTabs from "app/(posts)/components/tabs" import DocumentTabs from "src/app/(posts)/components/tabs"
import Link from "next/link" import Link from "next/link"
import { memo } from "react" import { memo } from "react"
import { Download, ExternalLink } from "react-feather" import { Download, ExternalLink } from "react-feather"

View file

@ -14,9 +14,10 @@ async function PostListWrapper({
const data = (await posts).filter((post) => post.visibility === "public") const data = (await posts).filter((post) => post.visibility === "public")
return ( return (
<PostList <PostList
morePosts={false}
userId={userId} userId={userId}
initialPosts={JSON.stringify(data)} initialPosts={JSON.stringify(data)}
hideSearch
hideActions
/> />
) )
} }
@ -62,9 +63,9 @@ export default async function UserPage({
<h1>Public posts by {user?.displayName || "Anonymous"}</h1> <h1>Public posts by {user?.displayName || "Anonymous"}</h1>
<Avatar /> <Avatar />
</div> </div>
<Suspense fallback={<PostList initialPosts={JSON.stringify({})} />}> <Suspense fallback={<PostList hideSearch skeleton initialPosts={[]} />}>
{/* @ts-expect-error because TS async JSX support is iffy */} {/* @ts-expect-error because TS async JSX support is iffy */}
<PostListWrapper posts={posts} userId={id} /> <PostListWrapper hideSearch posts={posts} userId={id} />
</Suspense> </Suspense>
</> </>
) )

View file

@ -3,12 +3,12 @@ import styles from "./badge.module.css"
type BadgeProps = { type BadgeProps = {
type: "primary" | "secondary" | "error" | "warning" type: "primary" | "secondary" | "error" | "warning"
children: React.ReactNode children: React.ReactNode
} } & React.HTMLAttributes<HTMLDivElement>
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>( const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ type, children }: BadgeProps, ref) => { ({ type, children, ...rest}: BadgeProps, ref) => {
return ( return (
<div className={styles.container}> <div className={styles.container} {...rest}>
<div className={`${styles.badge} ${styles[type]}`} ref={ref}> <div className={`${styles.badge} ${styles[type]}`} ref={ref}>
{children} {children}
</div> </div>

View file

@ -1,6 +1,8 @@
"use client" "use client"
import { useToasts } from "@components/toasts"
import Tooltip from "@components/tooltip" import Tooltip from "@components/tooltip"
import { timeAgo } from "@lib/time-ago" import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
import { timeAgo } from "src/app/lib/time-ago"
import { useMemo, useState, useEffect } from "react" import { useMemo, useState, useEffect } from "react"
import Badge from "../badge" import Badge from "../badge"
@ -8,6 +10,7 @@ const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
const createdDate = useMemo(() => new Date(createdAt), [createdAt]) const createdDate = useMemo(() => new Date(createdAt), [createdAt])
const [time, setTimeAgo] = useState(timeAgo(createdDate)) const [time, setTimeAgo] = useState(timeAgo(createdDate))
const { setToast } = useToasts()
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setTimeAgo(timeAgo(createdDate)) setTimeAgo(timeAgo(createdDate))
@ -15,11 +18,19 @@ const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [createdDate]) }, [createdDate])
function onClick() {
copyToClipboard(createdDate.toISOString())
setToast({
message: "Copied to clipboard",
type: "success"
})
}
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}` const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
return ( return (
// TODO: investigate tooltip not showing // TODO: investigate tooltip not showing
<Tooltip content={formattedTime}> <Tooltip content={formattedTime}>
<Badge type="secondary"> <Badge type="secondary" onClick={onClick}>
{" "} {" "}
<>{time}</> <>{time}</>
</Badge> </Badge>

View file

@ -1,7 +1,7 @@
"use client" "use client"
import Tooltip from "@components/tooltip" import Tooltip from "@components/tooltip"
import { timeUntil } from "@lib/time-ago" import { timeUntil } from "src/app/lib/time-ago"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Badge from "../badge" import Badge from "../badge"

View file

@ -39,6 +39,11 @@ const VisibilityControl = ({ authorId, postId, visibility: postVisibility }: Pro
const json = await res.json() const json = await res.json()
setVisibility(json.visibility) setVisibility(json.visibility)
router.refresh() router.refresh()
setToast({
message: "Visibility updated",
type: "success"
})
} else { } else {
setToast({ setToast({
message: "An error occurred", message: "An error occurred",
@ -47,7 +52,7 @@ const VisibilityControl = ({ authorId, postId, visibility: postVisibility }: Pro
setPasswordModalVisible(false) setPasswordModalVisible(false)
} }
}, },
[postId, setToast, setVisibility] [postId, router, setToast]
) )
const onSubmit = useCallback( const onSubmit = useCallback(

View file

@ -38,7 +38,7 @@
.header { .header {
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
opacity: .5; opacity: 0;
} }
.header:not(.loading) { .header:not(.loading) {

View file

@ -3,66 +3,42 @@
import styles from "./post-list.module.css" import styles from "./post-list.module.css"
import ListItem from "./list-item" import ListItem from "./list-item"
import { ChangeEvent, useCallback, useState } from "react" import { ChangeEvent, useCallback, useState } from "react"
import Link from "@components/link"
import type { PostWithFiles } from "@lib/server/prisma" import type { PostWithFiles } from "@lib/server/prisma"
import Input from "@components/input" import Input from "@components/input"
import Button from "@components/button"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { ListItemSkeleton } from "./list-item-skeleton" import { ListItemSkeleton } from "./list-item-skeleton"
import Link from "@components/link"
import debounce from "lodash.debounce" import debounce from "lodash.debounce"
type Props = { type Props = {
initialPosts: string | PostWithFiles[] initialPosts: string | PostWithFiles[]
morePosts?: boolean morePosts?: boolean
userId?: string
hideSearch?: boolean hideSearch?: boolean
hideActions?: boolean hideActions?: boolean
isOwner?: boolean isOwner?: boolean
skeleton?: boolean
searchValue?: string
userId?: string
} }
const PostList = ({ const PostList = ({
morePosts,
initialPosts: initialPostsMaybeJSON, initialPosts: initialPostsMaybeJSON,
userId,
hideSearch, hideSearch,
hideActions, hideActions,
isOwner isOwner,
skeleton,
userId
}: Props) => { }: Props) => {
const initialPosts = const initialPosts =
typeof initialPostsMaybeJSON === "string" typeof initialPostsMaybeJSON === "string"
? JSON.parse(initialPostsMaybeJSON) ? JSON.parse(initialPostsMaybeJSON)
: initialPostsMaybeJSON : initialPostsMaybeJSON
const [search, setSearchValue] = useState("") const [searchValue, setSearchValue] = useState("")
const [posts, setPosts] = useState<PostWithFiles[]>(initialPosts)
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const [hasMorePosts] = useState(morePosts) const [posts, setPosts] = useState<PostWithFiles[]>(initialPosts)
const { setToast } = useToasts() const { setToast } = useToasts()
const loadMoreClick = useCallback( const showSkeleton = skeleton || searching
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
if (hasMorePosts) {
// eslint-disable-next-line no-inner-declarations
async function fetchPosts() {
// const res = await fetch(`/api/posts/mine`, {
// method: "GET",
// headers: {
// "Content-Type": "application/json",
// "x-page": `${posts.length / 10 + 1}`
// },
// next: {
// revalidate: 10
// }
// })
// const json = await res.json()
// setPosts([...posts, ...json.posts])
// setHasMorePosts(json.morePosts)
}
fetchPosts()
}
},
[hasMorePosts]
)
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: address this // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: address this
const onSearch = useCallback( const onSearch = useCallback(
@ -131,27 +107,18 @@ const PostList = ({
disabled={!posts} disabled={!posts}
style={{ maxWidth: 300 }} style={{ maxWidth: 300 }}
aria-label="Search" aria-label="Search"
value={search} value={searchValue}
/> />
</div> </div>
)} )}
{!posts && <p style={{ color: "var(--warning)" }}>Failed to load.</p>} {!posts && <p style={{ color: "var(--warning)" }}>Failed to load.</p>}
{searching && ( {showSkeleton && (
<ul> <ul>
<ListItemSkeleton /> <ListItemSkeleton />
<ListItemSkeleton /> <ListItemSkeleton />
</ul> </ul>
)} )}
{!searching && posts?.length === 0 && posts && ( {!showSkeleton && posts?.length > 0 && (
<p>
No posts found. Create one{" "}
<Link colored href="/new">
here
</Link>
.
</p>
)}
{!searching && posts?.length > 0 && (
<div> <div>
<ul> <ul>
{posts.map((post) => { {posts.map((post) => {
@ -168,15 +135,20 @@ const PostList = ({
</ul> </ul>
</div> </div>
)} )}
{!searching && hasMorePosts && !setSearchValue && (
<div>
<Button width={"100%"} onClick={loadMoreClick}>
Load more
</Button>
</div>
)}
</div> </div>
) )
} }
export default PostList export default PostList
export function NoPostsFound() {
return (
<p>
No posts found. Create one{" "}
<Link colored href="/new">
here
</Link>
.
</p>
)
}

View file

@ -72,7 +72,7 @@ const ListItem = ({
<> <>
<div className={styles.title}> <div className={styles.title}>
<span className={styles.titleText}> <span className={styles.titleText}>
<h3 style={{ display: "inline-block", margin: 0 }}> <h4 style={{ display: "inline-block", margin: 0 }}>
<Link <Link
colored colored
style={{ marginRight: "var(--gap)" }} style={{ marginRight: "var(--gap)" }}
@ -80,7 +80,7 @@ const ListItem = ({
> >
{post.title} {post.title}
</Link> </Link>
</h3> </h4>
<div className={styles.badges}> <div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} /> <VisibilityBadge visibility={post.visibility} />
<Badge type="secondary"> <Badge type="secondary">

View file

@ -0,0 +1,73 @@
import { ApiToken } from "@prisma/client"
import useSWR from "swr"
type ConvertDateToString<T> = {
[P in keyof T]: T[P] extends Date ? string : T[P]
}
export type SerializedApiToken = ConvertDateToString<ApiToken>
type UseApiTokens = {
userId?: string
initialTokens?: SerializedApiToken[]
}
const TOKENS_ENDPOINT = "/api/user/tokens"
export function useApiTokens({ userId, initialTokens }: UseApiTokens) {
const { data, mutate, error, isLoading } = useSWR<SerializedApiToken[]>(
"/api/user/tokens?userId=" + userId,
async (url: string) => {
return fetch(url).then(async (res) => {
const data = await res.json()
if (data.error) {
throw new Error(data.error)
}
return data
})
},
{
refreshInterval: 10000,
fallbackData: initialTokens
}
)
async function createToken(newToken: string) {
if (!newToken) {
throw new Error("Token name is required")
}
const res = await fetch(
`${TOKENS_ENDPOINT}?userId=${userId}&name=${newToken}`,
{
method: "POST"
}
)
const response = await res.json()
if (response.error) {
throw new Error(response.error)
return
}
mutate([...(data || []), response])
return response as SerializedApiToken
}
const expireToken = async (id: string) => {
await fetch(`${TOKENS_ENDPOINT}?userId=${userId}&tokenId=${id}`, {
method: "DELETE"
})
mutate(data?.filter((token) => token.id !== id))
}
return {
data,
isLoading,
error,
createToken,
expireToken
}
}

View file

@ -1,6 +1,5 @@
import "@styles/globals.css" import "@styles/globals.css"
import { Providers } from "./providers" import { Providers } from "./providers"
// import { ServerThemeProvider } from "@wits/next-themes"
import Page from "@components/page" import Page from "@components/page"
import { Toasts } from "@components/toasts" import { Toasts } from "@components/toasts"
import Header from "@components/header" import Header from "@components/header"
@ -14,13 +13,6 @@ interface RootLayoutProps {
export default async function RootLayout({ children }: RootLayoutProps) { export default async function RootLayout({ children }: RootLayoutProps) {
return ( return (
// <ServerThemeProvider
// enableSystem={true}
// disableTransitionOnChange
// cookieName={"drift-theme"}
// attribute="data-theme"
// enableColorScheme={true}
// >
<html lang="en" className={inter.variable}> <html lang="en" className={inter.variable}>
<head /> <head />
<body> <body>

View file

@ -0,0 +1,3 @@
export async function copyToClipboard(text: string ) {
await navigator.clipboard.writeText(text)
}

View file

@ -0,0 +1,17 @@
// A wrapper around next-auth/use-session that refreshes the page if the session changes
import { useEffect } from "react"
import { useSession as useSessionOriginal } from "next-auth/react"
import { useRouter } from "next/navigation"
export function useSession() {
const { data: session, status } = useSessionOriginal()
const router = useRouter();
useEffect(() => {
router.refresh();
}, [router, status])
return { session, status }
}

View file

@ -1,13 +1,8 @@
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { getPostsByUser, User } from "@lib/server/prisma" import { getPostsByUser } from "@lib/server/prisma"
import PostList from "@components/post-list" import PostList from "@components/post-list"
import { getCurrentUser } from "@lib/server/session" import { getCurrentUser } from "@lib/server/session"
import { authOptions } from "@lib/server/auth" 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() { export default async function Mine() {
const userId = (await getCurrentUser())?.id const userId = (await getCurrentUser())?.id
@ -16,16 +11,17 @@ export default async function Mine() {
return redirect(authOptions.pages?.signIn || "/new") return redirect(authOptions.pages?.signIn || "/new")
} }
const posts = await cachedGetPostsByUser(userId) const posts = await getPostsByUser(userId, true)
const hasMore = false
const stringifiedPosts = JSON.stringify(posts) const stringifiedPosts = JSON.stringify(posts)
return ( return (
<PostList <PostList
userId={userId} userId={userId}
morePosts={hasMore}
initialPosts={stringifiedPosts} initialPosts={stringifiedPosts}
isOwner={true} isOwner={true}
hideSearch={false}
/> />
) )
} }
export const revalidate = 0;

View file

@ -1,9 +1,10 @@
import Image from "next/image" import Image from "next/image"
import Card from "@components/card" import Card from "@components/card"
import { getWelcomeContent } from "pages/api/welcome" import { getWelcomeContent } from "src/pages/api/welcome"
import DocumentTabs from "./(posts)/components/tabs" import DocumentTabs from "./(posts)/components/tabs"
import { getAllPosts, Post } from "@lib/server/prisma" import { getAllPosts, Post } from "@lib/server/prisma"
import PostList from "@components/post-list" import PostList, { NoPostsFound } from "@components/post-list"
import { Suspense } from "react"
const getWelcomeData = async () => { const getWelcomeData = async () => {
const welcomeContent = await getWelcomeContent() const welcomeContent = await getWelcomeContent()
@ -66,8 +67,12 @@ export default async function Page() {
</Card> </Card>
<div> <div>
<h2>Recent public posts</h2> <h2>Recent public posts</h2>
<Suspense
fallback={<PostList skeleton hideSearch initialPosts={JSON.stringify({})} />}
>
{/* @ts-expect-error because of async RSC */} {/* @ts-expect-error because of async RSC */}
<PublicPostList getPostsPromise={getPostsPromise} /> <PublicPostList getPostsPromise={getPostsPromise} />
</Suspense>
</div> </div>
</div> </div>
) )
@ -80,25 +85,16 @@ async function PublicPostList({
}) { }) {
try { try {
const posts = await getPostsPromise const posts = await getPostsPromise
if (posts.length === 0) {
return <NoPostsFound />
}
return ( return (
<PostList <PostList initialPosts={JSON.stringify(posts)} hideActions hideSearch />
userId={undefined}
morePosts={false}
initialPosts={JSON.stringify(posts)}
hideActions
hideSearch
/>
) )
} catch (error) { } catch (error) {
return ( return <NoPostsFound />
<PostList
userId={undefined}
morePosts={false}
initialPosts={[]}
hideActions
hideSearch
/>
)
} }
} }

View file

@ -2,36 +2,18 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap); gap: var(--gap);
max-width: 300px; max-width: 350px;
margin-top: var(--gap); margin-top: var(--gap);
} }
.upload { /* fieldset contains an input and button. I want button to be small next to input */
position: relative; .form .fieldset {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: var(--gap); gap: var(--gap);
max-width: 300px; border: none;
margin-top: var(--gap); padding: 0;
cursor: pointer; margin: 0;
}
.uploadInput {
position: absolute;
opacity: 0;
cursor: pointer;
width: 300px;
height: 37px;
cursor: pointer;
}
.uploadButton {
width: 100%;
}
/* hover should affect button */
.uploadInput:hover + button {
border: 1px solid var(--fg);
} }
.tokens { .tokens {

View file

@ -5,97 +5,56 @@ import Input from "@components/input"
import Note from "@components/note" import Note from "@components/note"
import { Spinner } from "@components/spinner" import { Spinner } from "@components/spinner"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { ApiToken } from "@prisma/client" import { SerializedApiToken, useApiTokens } from "src/app/hooks/swr/use-api-tokens"
import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
import { useSession } from "next-auth/react" import { useSession } from "next-auth/react"
import { useState } from "react" import { useState } from "react"
import useSWR from "swr"
import styles from "./api-keys.module.css" import styles from "./api-keys.module.css"
type ConvertDateToString<T> = {
[P in keyof T]: T[P] extends Date ? string : T[P]
}
type SerializedApiToken = ConvertDateToString<ApiToken>
// need to pass in the accessToken // need to pass in the accessToken
const APIKeys = ({ tokens: initialTokens }: { tokens?: SerializedApiToken[] }) => { const APIKeys = ({
tokens: initialTokens
}: {
tokens?: SerializedApiToken[]
}) => {
const session = useSession() const session = useSession()
const { setToast } = useToasts() const { setToast } = useToasts()
const { data, error, mutate } = useSWR<SerializedApiToken[]>( const { data, error, createToken, expireToken } = useApiTokens({
"/api/user/tokens?userId=" + session?.data?.user?.id, userId: session.data?.user.id,
{ initialTokens
fetcher: async (url: string) => {
if (session.status === "loading") return initialTokens
return fetch(url).then(async (res) => {
const data = await res.json()
if (data.error) {
setError(data.error)
return
} else {
setError(undefined)
}
return data
}) })
},
fallbackData: initialTokens
}
)
const [submitting, setSubmitting] = useState<boolean>(false) const [submitting, setSubmitting] = useState<boolean>(false)
const [newToken, setNewToken] = useState<string>("") const [newToken, setNewToken] = useState<string>("")
const [errorText, setError] = useState<string>()
const createToken = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
if (!newToken) {
return
}
setSubmitting(true)
const res = await fetch(
`/api/user/tokens?userId=${session.data?.user.id}&name=${newToken}`,
{
method: "POST",
}
)
const response = await res.json()
if (response.error) {
setError(response.error)
return
} else {
setError(undefined)
}
setSubmitting(false)
navigator.clipboard.writeText(response.token)
mutate([...(data || []), response])
setNewToken("")
setToast({
message: "Copied to clipboard!",
type: "success"
})
}
const expireToken = async (id: string) => {
setSubmitting(true)
await fetch(`/api/user/tokens?userId=${session.data?.user.id}&tokenId=${id}`, {
method: "DELETE",
headers: {
Authorization: "Bearer " + session?.data?.user.sessionToken
}
})
setSubmitting(false)
mutate(data?.filter((token) => token.id !== id))
}
const onChangeNewToken = (e: React.ChangeEvent<HTMLInputElement>) => { const onChangeNewToken = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewToken(e.target.value) setNewToken(e.target.value)
} }
const hasError = Boolean(error || errorText) const onCreateTokenClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
setSubmitting(true)
try {
const createdToken = await createToken(newToken)
setNewToken("")
await copyToClipboard(createdToken?.token || "")
setToast({
message: "Your new API key has been copied to your clipboard.",
type: "success"
})
setSubmitting(false)
} catch (e) {
if (e instanceof Error) {
setToast({
message: e.message,
type: "error"
})
}
setSubmitting(false)
}
}
const hasError = Boolean(error)
return ( return (
<> <>
{!hasError && ( {!hasError && (
@ -103,10 +62,10 @@ const APIKeys = ({ tokens: initialTokens }: { tokens?: SerializedApiToken[] }) =
API keys allow you to access the API from 3rd party tools. API keys allow you to access the API from 3rd party tools.
</Note> </Note>
)} )}
{hasError && <Note type="error">{error?.message || errorText}</Note>} {hasError && <Note type="error">{error?.message}</Note>}
<form className={styles.form}> <form className={styles.form}>
<h3>Create new</h3> <h5>Create new</h5>
<fieldset className={styles.fieldset}>
<Input <Input
type="text" type="text"
value={newToken} value={newToken}
@ -116,18 +75,19 @@ const APIKeys = ({ tokens: initialTokens }: { tokens?: SerializedApiToken[] }) =
/> />
<Button <Button
type="button" type="button"
onClick={createToken} onClick={onCreateTokenClick}
loading={submitting} loading={submitting}
disabled={!newToken} disabled={!newToken}
> >
Submit Submit
</Button> </Button>
</fieldset>
</form> </form>
<div className={styles.tokens}> <div className={styles.tokens}>
{data ? ( {data ? (
data?.length ? ( data?.length ? (
<table width={'100%'}> <table width={"100%"}>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
@ -144,7 +104,6 @@ const APIKeys = ({ tokens: initialTokens }: { tokens?: SerializedApiToken[] }) =
<Button <Button
type="button" type="button"
onClick={() => expireToken(token.id)} onClick={() => expireToken(token.id)}
loading={submitting}
> >
Revoke Revoke
</Button> </Button>

View file

@ -5,7 +5,7 @@ import Input from "@components/input"
import Note from "@components/note" import Note from "@components/note"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { useSession } from "next-auth/react" import { useSession } from "next-auth/react"
import { useState } from "react" import { useEffect, useState } from "react"
import styles from "./profile.module.css" import styles from "./profile.module.css"
const Profile = () => { const Profile = () => {
@ -14,6 +14,12 @@ const Profile = () => {
const [submitting, setSubmitting] = useState<boolean>(false) const [submitting, setSubmitting] = useState<boolean>(false)
const { setToast } = useToasts() const { setToast } = useToasts()
useEffect(() => {
if (!name) {
setName(session?.user.name || "")
}
}, [name, session])
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value) setName(e.target.value)
} }
@ -71,7 +77,7 @@ const Profile = () => {
return ( return (
<> <>
<Note type="warning"> <Note type="warning">
This information will be publicly available on your profile. Your display name is publicly available on your profile.
</Note> </Note>
<form onSubmit={onSubmit} className={styles.form}> <form onSubmit={onSubmit} className={styles.form}>
<div> <div>

View file

@ -11,7 +11,8 @@ export default function SettingsLayout({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: "var(--gap)", gap: "var(--gap)",
marginBottom: "var(--gap)" marginBottom: "var(--gap)",
marginTop: "var(--gap)"
}} }}
> >
{children} {children}

View file

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

View file

@ -176,3 +176,42 @@ textarea:focus {
border-color: var(--light-gray); border-color: var(--light-gray);
outline: none; outline: none;
} }
h1 {
font-size: 2.5rem;
margin: 0;
}
h2 {
font-size: 2rem;
margin: 0;
}
h3 {
font-size: 1.5rem;
margin: 0;
}
h4 {
font-size: 1.25rem;
margin: 0;
}
h5 {
font-size: 1rem;
margin: 0;
}
h6 {
font-size: 0.875rem;
margin: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 700;
}

View file

@ -0,0 +1,9 @@
import config from "./config"
export const revalidatePage = async (path: string) => {
const res = await fetch(
`${process.env.DRIFT_URL}/api/revalidate?secret=${config.nextauth_secret}&path=${path}`
)
const json = await res.json()
return json
}

View file

@ -119,6 +119,7 @@ const providers = () => {
} }
}) })
console.log("New user created")
return newUser return newUser
} }
} }

View file

@ -117,6 +117,7 @@ export async function getPostsByUser(userId: User["id"], withFiles?: boolean) {
}) })
} }
}) })
console.log(posts)
return posts return posts
} }
@ -272,6 +273,7 @@ export const searchPosts = async (
} }
} }
}, },
authorId: userId,
visibility: publicOnly ? "public" : undefined visibility: publicOnly ? "public" : undefined
} }
] ]

View file

@ -46,9 +46,18 @@ const parseAndCheckAuthToken = async (req: NextApiRequest) => {
token token
}, },
select: { select: {
userId: true userId: true,
expiresAt: true
} }
}) })
if (!user) {
return null
}
if (user.expiresAt < new Date()) {
return null
}
return user?.userId return user?.userId
} }

View file

@ -1,7 +1,7 @@
import { withMethods } from "@lib/api-middleware/with-methods" import { withMethods } from "@lib/api-middleware/with-methods"
import { parseQueryParam } from "@lib/server/parse-query-param" import { parseQueryParam } from "@lib/server/parse-query-param"
import { NextApiRequest, NextApiResponse } from "next" import { NextApiRequest, NextApiResponse } from "next"
import { prisma } from "lib/server/prisma" import { prisma } from "src/lib/server/prisma"
import { getSession } from "next-auth/react" import { getSession } from "next-auth/react"
import { deleteUser } from "../user/[id]" import { deleteUser } from "../user/[id]"

View file

@ -1,5 +1,5 @@
import { NextApiRequest, NextApiResponse } from "next" import { NextApiRequest, NextApiResponse } from "next"
import { prisma } from "lib/server/prisma" import { prisma } from "src/lib/server/prisma"
import { parseQueryParam } from "@lib/server/parse-query-param" import { parseQueryParam } from "@lib/server/parse-query-param"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => { const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {

View file

@ -3,7 +3,7 @@ import { parseQueryParam } from "@lib/server/parse-query-param"
import { getPostById } from "@lib/server/prisma" import { getPostById } from "@lib/server/prisma"
import type { NextApiRequest, NextApiResponse } from "next" import type { NextApiRequest, NextApiResponse } from "next"
import { getSession } from "next-auth/react" import { getSession } from "next-auth/react"
import { prisma } from "lib/server/prisma" import { prisma } from "src/lib/server/prisma"
import * as crypto from "crypto" import * as crypto from "crypto"
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {

View file

@ -95,7 +95,6 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse<unknown>) {
.catch((error) => { .catch((error) => {
return res.status(500).json(error) return res.status(500).json(error)
}) })
return res.json(post) return res.json(post)
} catch (error) { } catch (error) {
return res.status(500).json(error) return res.status(500).json(error)

View file

@ -2,12 +2,12 @@ import { withMethods } from "@lib/api-middleware/with-methods"
import { parseQueryParam } from "@lib/server/parse-query-param" import { parseQueryParam } from "@lib/server/parse-query-param"
import { searchPosts, ServerPostWithFiles } from "@lib/server/prisma" import { searchPosts, ServerPostWithFiles } from "@lib/server/prisma"
import { NextApiRequest, NextApiResponse } from "next" import { NextApiRequest, NextApiResponse } from "next"
import { getSession } from "next-auth/react" import { unstable_getServerSession } from "next-auth"
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { q, userId } = req.query const { q, userId } = req.query
const session = await getSession() const session = await unstable_getServerSession()
const query = parseQueryParam(q) const query = parseQueryParam(q)
const user = parseQueryParam(userId) const user = parseQueryParam(userId)

View file

@ -0,0 +1,28 @@
// https://beta.nextjs.org/docs/data-fetching/revalidating#on-demand-revalidation
import config from "@lib/config"
import { parseQueryParam } from "@lib/server/parse-query-param"
import type { NextApiRequest, NextApiResponse } from "next"
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// TODO: create a new secret?
if (req.query.secret !== config.nextauth_secret) {
return res.status(401).json({ message: "Invalid token" })
}
const path = parseQueryParam(req.query.path)
try {
if (path) {
await res.revalidate(path)
return res.json({ revalidated: true })
}
} catch (err) {
// If there was an error, Next.js will continue
// to show the last successfully generated page
return res.status(500).send("Error revalidating")
}
}

View file

@ -1,7 +1,7 @@
import { parseQueryParam } from "@lib/server/parse-query-param" import { parseQueryParam } from "@lib/server/parse-query-param"
import { getUserById } from "@lib/server/prisma" import { getUserById } from "@lib/server/prisma"
import { NextApiRequest, NextApiResponse } from "next" import { NextApiRequest, NextApiResponse } from "next"
import { prisma } from "lib/server/prisma" import { prisma } from "src/lib/server/prisma"
import { withMethods } from "@lib/api-middleware/with-methods" import { withMethods } from "@lib/api-middleware/with-methods"
import { getSession } from "next-auth/react" import { getSession } from "next-auth/react"

View file

@ -39,13 +39,13 @@
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@components/*": [ "@components/*": [
"app/components/*" "src/app/components/*"
], ],
"@lib/*": [ "@lib/*": [
"lib/*" "src/lib/*"
], ],
"@styles/*": [ "@styles/*": [
"app/styles/*" "src/app/styles/*"
] ]
} }
}, },