From 4b74f47e7501b1534659e67fddd091dc389d0525 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 11 Sep 2023 00:40:14 -0500 Subject: [PATCH] feat: display user's posts on profile page --- convex/posts.ts | 14 ++++- convex/schema.ts | 32 ++++++++++- convex/users.ts | 48 ++++++++++++----- next.config.js | 16 ++++-- package-lock.json | 88 +++++++++++++++++++++++-------- package.json | 3 +- src/app/profile/[id]/page.tsx | 25 ++++++++- src/components/Editor.tsx | 15 ++++-- src/components/Navbar.tsx | 17 ++---- src/components/Post.tsx | 55 +++++++++++++++++++ src/components/SpinningLoader.tsx | 5 ++ src/components/ThemeButton.tsx | 7 ++- src/components/UserButton.tsx | 27 ++++++++++ src/components/UserPosts.tsx | 38 +++++++++++++ src/components/UserProfile.tsx | 38 +++++++++++++ src/hooks/index.ts | 11 +++- src/lib/convex/index.ts | 22 ++++++++ src/lib/utils/index.ts | 3 ++ 18 files changed, 397 insertions(+), 67 deletions(-) create mode 100644 src/components/Post.tsx create mode 100644 src/components/SpinningLoader.tsx create mode 100644 src/components/UserButton.tsx create mode 100644 src/components/UserPosts.tsx create mode 100644 src/components/UserProfile.tsx create mode 100644 src/lib/convex/index.ts create mode 100644 src/lib/utils/index.ts diff --git a/convex/posts.ts b/convex/posts.ts index 75397a5..20ae560 100644 --- a/convex/posts.ts +++ b/convex/posts.ts @@ -1,5 +1,6 @@ +import { paginationOptsValidator } from 'convex/server'; import { v } from 'convex/values'; -import { mutation } from './_generated/server'; +import { mutation, query } from './_generated/server'; import { userQuery } from './users'; export const createPost = mutation({ @@ -31,3 +32,14 @@ export const createPost = mutation({ }); }, }); + +export const getUsersPostById = query({ + args: { userId: v.id('users'), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + return await ctx.db + .query('posts') + .withIndex('by_user_id', q => q.eq('userId', args.userId)) + .order('desc') + .paginate(args.paginationOpts); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 7065f6c..7fa6d9c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -3,7 +3,37 @@ import { v } from 'convex/values'; export default defineSchema( { users: defineTable({ - clerkUser: v.any(), + clerkUser: v.object({ + id: v.string(), + object: v.string(), + username: v.union(v.string(), v.null()), + first_name: v.string(), + last_name: v.string(), + gender: v.string(), + birthday: v.string(), + profile_image_url: v.string(), + image_url: v.string(), + has_image: v.boolean(), + primary_email_address_id: v.boolean(), + primary_phone_number_id: v.union(v.string(), v.null()), + primary_web3_wallet_id: v.union(v.string(), v.null()), + password_enabled: v.boolean(), + totp_enabled: v.boolean(), + backup_code_enabled: v.boolean(), + two_factor_enabled: v.boolean(), + banned: v.boolean(), + email_addresses: v.any(), + phone_numbers: v.any(), + web3_wallets: v.any(), + external_accounts: v.any(), + external_id: v.union(v.string(), v.null()), + last_sign_in_at: v.union(v.number(), v.null()), + public_metadata: v.any(), + private_metadata: v.any(), + unsafe_metadata: v.any(), + created_at: v.number(), + updated_at: v.number(), + }), }).index('by_clerk_id', ['clerkUser.id']), posts: defineTable({ parentPostId: v.optional(v.id('posts')), diff --git a/convex/users.ts b/convex/users.ts index c4136ed..0a1f8f4 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,7 +1,37 @@ -import { UserJSON } from '@clerk/nextjs/dist/types/server'; import { v } from 'convex/values'; -import { Doc } from './_generated/dataModel'; -import { QueryCtx, internalMutation, internalQuery } from './_generated/server'; +import { + QueryCtx, + internalMutation, + internalQuery, + query, +} from './_generated/server'; + +export async function userQuery(ctx: QueryCtx, clerkUserId: string) { + return await ctx.db + .query('users') + .withIndex('by_clerk_id', q => q.eq('clerkUser.id', clerkUserId)) + .unique(); +} + +export const getUserByClerkId = query({ + args: { clerkUserId: v.string() }, + async handler(ctx, args) { + return await userQuery(ctx, args.clerkUserId); + }, +}); + +export const getUserById = query({ + args: { id: v.id('users') }, + async handler(ctx, args) { + const user = await ctx.db.get(args.id); + + if (user === null) { + return 'USER_NOT_FOUND'; + } + + return user; + }, +}); export const getUser = internalQuery({ args: { subject: v.string() }, @@ -10,19 +40,9 @@ export const getUser = internalQuery({ }, }); -export async function userQuery( - ctx: QueryCtx, - clerkUserId: string -): Promise<(Omit, 'clerkUser'> & { clerkUser: UserJSON }) | null> { - return await ctx.db - .query('users') - .withIndex('by_clerk_id', q => q.eq('clerkUser.id', clerkUserId)) - .unique(); -} - export const updateOrCreateUser = internalMutation({ args: { clerkUser: v.any() }, - async handler(ctx, { clerkUser }: { clerkUser: UserJSON }) { + async handler(ctx, { clerkUser }) { const userRecord = await userQuery(ctx, clerkUser.id); if (userRecord === null) { diff --git a/next.config.js b/next.config.js index 767719f..ac4f746 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,14 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = {} +const { hostname } = require('os'); -module.exports = nextConfig +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + hostname: '*', + }, + ], + }, +}; + +module.exports = nextConfig; diff --git a/package-lock.json b/package-lock.json index 8b58dd4..f456dd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@types/node": "20.6.0", "@types/react": "18.2.21", "@types/react-dom": "18.2.7", + "@types/react-syntax-highlighter": "^15.5.7", "autoprefixer": "10.4.15", "codemirror": "^6.0.1", "convex": "^1.2.1", @@ -38,7 +39,7 @@ "typescript": "5.2.2" }, "devDependencies": { - "@types/react-syntax-highlighter": "^15.5.7" + "encoding": "^0.1.13" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -1486,7 +1487,6 @@ "version": "15.5.7", "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.7.tgz", "integrity": "sha512-bo5fEO5toQeyCp0zVHBeggclqf5SQ/Z5blfFmjwO5dkMVGPgmiwZsJh9nu/Bo5L7IHTuGWrja6LxJVE2uB5ZrQ==", - "dev": true, "dependencies": { "@types/react": "*" } @@ -2272,6 +2272,25 @@ } } }, + "node_modules/convex/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -2450,6 +2469,15 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "devOptional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/enhanced-resolve": { "version": "5.15.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", @@ -3529,6 +3557,18 @@ "node": "*" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "devOptional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -5148,25 +5188,6 @@ "tslib": "^2.0.3" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-fetch-native": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.0.1.tgz", @@ -6022,6 +6043,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -6350,6 +6377,25 @@ "whatwg-fetch": "^3.4.1" } }, + "node_modules/svix-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/swr": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.0.tgz", diff --git a/package.json b/package.json index 838d8d2..e05fa6f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@types/node": "20.6.0", "@types/react": "18.2.21", "@types/react-dom": "18.2.7", + "@types/react-syntax-highlighter": "^15.5.7", "autoprefixer": "10.4.15", "codemirror": "^6.0.1", "convex": "^1.2.1", @@ -37,6 +38,6 @@ "svix": "^1.11.0", "tailwindcss": "3.3.3", "typescript": "5.2.2", - "@types/react-syntax-highlighter": "^15.5.7" + "encoding": "^0.1.13" } } \ No newline at end of file diff --git a/src/app/profile/[id]/page.tsx b/src/app/profile/[id]/page.tsx index 9a4f4f6..e85aabb 100644 --- a/src/app/profile/[id]/page.tsx +++ b/src/app/profile/[id]/page.tsx @@ -1,8 +1,29 @@ +import UserPosts from '@/components/UserPosts'; +import UserProfile from '@/components/UserProfile'; +import { getConvexClient } from '@/lib/convex'; +import { api } from '../../../../convex/_generated/api'; + export default async function UserProfilePage({ params, }: { params: { id: string }; }) { - // TODO: Profile page. Display profile and user's post - return

{params.id}

; + const client = await getConvexClient(); + const user = await client.query(api.users.getUserByClerkId, { + clerkUserId: params.id, + }); + + if (user === null) { + // TODO: Show real not found component + return
Not Found
; + } + + return ( +
+
+ + +
+
+ ); } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index b21b671..8c6609f 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -17,12 +17,13 @@ import { api } from '../../convex/_generated/api'; import PostContent from './PostContent'; export default function Editor() { - const user = useUser(); + const { user, isSignedIn } = useUser(); const { theme } = useTheme(); const editorTheme = new Compartment(); const [currentDoc, setCurrentDoc] = useState(['']); const createPost = useMutation(api.posts.createPost); const router = useRouter(); + const [creating, setCreating] = useState(false); const doc = ['']; const extensions = [ @@ -55,13 +56,15 @@ export default function Editor() { async function handleSubmit(e: FormEvent) { e.preventDefault(); try { - if (user.isSignedIn !== true) { + if (isSignedIn !== true) { router.push('/login'); return; } + setCreating(true); + const result = await createPost({ - clerkUserId: user.user.id, + clerkUserId: user.id, content: currentDoc, }); @@ -76,6 +79,8 @@ export default function Editor() { } } catch (error) { console.log(error); + } finally { + setCreating(false); } } @@ -121,8 +126,8 @@ export default function Editor() {
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index f8e4505..7ee0a39 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,7 +1,8 @@ -import { SignedIn, SignedOut, UserButton, currentUser } from '@clerk/nextjs'; +import { SignedIn, SignedOut, currentUser } from '@clerk/nextjs'; import Link from 'next/link'; import { BiSolidMessageSquareAdd } from 'react-icons/bi'; import ThemeButton from './ThemeButton'; +import UserButton from './UserButton'; export default async function Navbar() { const user = await currentUser(); @@ -50,19 +51,7 @@ export default async function Navbar() {
  • - +
  • diff --git a/src/components/Post.tsx b/src/components/Post.tsx new file mode 100644 index 0000000..e0e7903 --- /dev/null +++ b/src/components/Post.tsx @@ -0,0 +1,55 @@ +import { Text } from '@codemirror/state'; +import { useQuery } from 'convex/react'; +import Image from 'next/image'; +import { api } from '../../convex/_generated/api'; +import { Doc } from '../../convex/_generated/dataModel'; +import PostContent from './PostContent'; +import SpinningLoader from './SpinningLoader'; + +export default function Post({ post }: { post: Doc<'posts'> }) { + const user = useQuery(api.users.getUserById, { id: post.userId }); + + if (user === undefined) { + return ( +
    + +
    + ); + } + + if (user === 'USER_NOT_FOUND') { + return
    User for post not found
    ; + } + + const username = user.clerkUser.username ?? user._id; + const createdPostDate = new Date(post._creationTime); + const postYear = createdPostDate.getFullYear(); + const postMonth = createdPostDate.toLocaleString(undefined, { + month: 'short', + }); + const dayOfMonth = createdPostDate.getDate(); + + return ( +
    +
    + user profile image +
    +
    +
    +
    {username}
    +
    {`${postMonth} ${dayOfMonth}, ${postYear}`}
    +
    +
    + +
    +
    +
    +
    + ); +} diff --git a/src/components/SpinningLoader.tsx b/src/components/SpinningLoader.tsx new file mode 100644 index 0000000..9a9d816 --- /dev/null +++ b/src/components/SpinningLoader.tsx @@ -0,0 +1,5 @@ +import { ImSpinner2 } from 'react-icons/im'; + +export default function SpinningLoader({ className }: { className: string }) { + return ; +} diff --git a/src/components/ThemeButton.tsx b/src/components/ThemeButton.tsx index 9df080e..3184b7d 100644 --- a/src/components/ThemeButton.tsx +++ b/src/components/ThemeButton.tsx @@ -1,16 +1,15 @@ 'use client'; +import { useMountedEffect } from '@/hooks'; import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; import { BsMoonStarsFill, BsSunFill } from 'react-icons/bs'; import { ImSpinner2 } from 'react-icons/im'; export default function ThemeButton() { - const [mounted, setMounted] = useState(false); + const { mounted, Loader } = useMountedEffect(); const { theme, setTheme } = useTheme(); - useEffect(() => setMounted(true), []); - const isDark = theme === 'dark'; return ( @@ -19,7 +18,7 @@ export default function ThemeButton() { onClick={() => setTheme(isDark ? 'light' : 'dark')} > {mounted === false ? ( - + ) : isDark ? ( ) : ( diff --git a/src/components/UserButton.tsx b/src/components/UserButton.tsx new file mode 100644 index 0000000..9323c32 --- /dev/null +++ b/src/components/UserButton.tsx @@ -0,0 +1,27 @@ +'use client'; +import { useMountedEffect } from '@/hooks'; +import { UserButton as ClerkUserButton } from '@clerk/nextjs'; + +export default function UserButton() { + const { mounted } = useMountedEffect(); + + if (mounted === false) { + return; + } + + return ( + + ); +} diff --git a/src/components/UserPosts.tsx b/src/components/UserPosts.tsx new file mode 100644 index 0000000..2b56f63 --- /dev/null +++ b/src/components/UserPosts.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { usePaginatedQuery } from 'convex/react'; +import { api } from '../../convex/_generated/api'; +import { Doc } from '../../convex/_generated/dataModel'; +import Post from './Post'; + +export default function UserPosts({ user }: { user: Doc<'users'> }) { + const PAGE_SIZE = 10; + const pager = usePaginatedQuery( + api.posts.getUsersPostById, + { + userId: user._id, + }, + { initialNumItems: 10 } + ); + + return ( +
    + {pager.results.map(post => ( + + ))} + {pager.isLoading ? ( +
    Loading...
    + ) : ( + + )} +
    + ); +} diff --git a/src/components/UserProfile.tsx b/src/components/UserProfile.tsx new file mode 100644 index 0000000..44c2330 --- /dev/null +++ b/src/components/UserProfile.tsx @@ -0,0 +1,38 @@ +import Image from 'next/image'; +import { AiFillCalendar } from 'react-icons/ai'; +import { Doc } from '../../convex/_generated/dataModel'; + +export default function UserProfile({ user }: { user: Doc<'users'> }) { + const username = user.clerkUser.username ?? user._id; + const userCreatedDate = new Date(user._creationTime); + const monthJoined = userCreatedDate.toLocaleString(undefined, { + month: 'short', + }); + const yearJoined = userCreatedDate.getFullYear(); + + return ( +
    +
    +
    +
    + user profile image +
    +
    +

    {username}

    +
    + + {`Joined ${monthJoined} ${yearJoined}`} +
    +
    +
    +
    +
    +
    + ); +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 6cd0633..052762d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ +import SpinningLoader from '@/components/SpinningLoader'; import { EditorState, Extension, Text } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; import { useEffect, useRef, useState } from 'react'; @@ -30,7 +31,15 @@ export function useCodeMirror({ setEditorView(view); return () => view.destroy(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editorRef]); + }, [editorRef.current]); return { editorRef, editorView }; } + +export function useMountedEffect() { + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + return { mounted, Loader: SpinningLoader }; +} diff --git a/src/lib/convex/index.ts b/src/lib/convex/index.ts new file mode 100644 index 0000000..69df00d --- /dev/null +++ b/src/lib/convex/index.ts @@ -0,0 +1,22 @@ +import { auth } from '@clerk/nextjs'; +import { ConvexHttpClient } from 'convex/browser'; + +export async function getConvexClient() { + const { user, getToken } = auth(); + const token = await getToken({ template: 'convex' }); + + if (token === null || user === null) { + throw new Error('NOT_AUTHORIZED'); + } + + const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL; + + if (CONVEX_URL === undefined) { + throw new Error('NEXT_PUBLIC_CONVEX_URL is undefined'); + } + + const client = new ConvexHttpClient(CONVEX_URL); + client.setAuth(token); + + return client; +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts new file mode 100644 index 0000000..254e8fc --- /dev/null +++ b/src/lib/utils/index.ts @@ -0,0 +1,3 @@ +export function getFullMonth() { + const monthMap = {}; +}