From b13805b958616e42344e5b4bb07ff12a55bc9d1b Mon Sep 17 00:00:00 2001 From: Gurpal Singh Date: Sun, 4 Feb 2024 20:28:50 +0530 Subject: [PATCH] [web] add caching for user profile --- apps/web/app/[username]/challenges/page.tsx | 65 ++++++++ apps/web/app/[username]/layout.client.tsx | 79 ++++++++++ apps/web/app/[username]/layout.tsx | 96 ++++++++++++ apps/web/app/[username]/page.tsx | 142 +++--------------- apps/web/app/settings/actions.ts | 2 + .../[track]/challenge/[challenge]/action.ts | 4 + apps/web/cache/user.ts | 84 +++++++++++ apps/web/components/ui/table.tsx | 117 +++++++++++++++ 8 files changed, 466 insertions(+), 123 deletions(-) create mode 100644 apps/web/app/[username]/challenges/page.tsx create mode 100644 apps/web/app/[username]/layout.client.tsx create mode 100644 apps/web/app/[username]/layout.tsx create mode 100644 apps/web/cache/user.ts create mode 100644 apps/web/components/ui/table.tsx diff --git a/apps/web/app/[username]/challenges/page.tsx b/apps/web/app/[username]/challenges/page.tsx new file mode 100644 index 0000000..acf035f --- /dev/null +++ b/apps/web/app/[username]/challenges/page.tsx @@ -0,0 +1,65 @@ +import { getCachedSolvedChallenges } from "@/cache/user"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { fromNow } from "@/utils/time"; + +import { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Solved Challenges", +}; + +async function Challenges({ params }: { params: { username: string } }) { + const username = decodeURIComponent(params.username).replace("@", ""); + const challenges = await getCachedSolvedChallenges(username); + return ( + <> +
+

+ Solved Challenges +

+
+
+ {challenges.length > 0 ? ( + + + + Name + Latest Submission + + + + {challenges.map((challenge) => ( + + + + {challenge.label} + + + + {fromNow(challenge.solves[0].createdAt)} + + + ))} + +
+ ) : null} +
+ + ); +} + +export default Challenges; diff --git a/apps/web/app/[username]/layout.client.tsx b/apps/web/app/[username]/layout.client.tsx new file mode 100644 index 0000000..e76227f --- /dev/null +++ b/apps/web/app/[username]/layout.client.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { getCacheSolvesCount, getCacheUser } from "@/cache/user"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { AudioWaveform, BadgeCheck, CheckCheck } from "lucide-react"; +import { useSession } from "next-auth/react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { IoCog } from "react-icons/io5"; + +function LayoutClientComponents({ username }: { username: string }) { + const { data: session } = useSession(); + return ( + <> + {session?.user?.username?.toLowerCase() === username.toLowerCase() ? ( + + + Settings + + ) : null} + + ); +} + +export default LayoutClientComponents; + +export function ProfileButtons({ + user, + username, + solves, +}: { + username: string; + user: NonNullable>>; + solves: NonNullable>>; +}) { + const pathname = usePathname() + .replaceAll(`/@${username}/`, "") + .replace("/", ""); + return ( +
+ + + {solves?.[0]?.solves_count || 0}{" "} + {solves?.[0]?.solves_count <= 1 ? "Challenge" : "Challenges"} Conquered + + + + +
+ ); +} diff --git a/apps/web/app/[username]/layout.tsx b/apps/web/app/[username]/layout.tsx new file mode 100644 index 0000000..159a62e --- /dev/null +++ b/apps/web/app/[username]/layout.tsx @@ -0,0 +1,96 @@ +import { PropsWithChildren } from "react"; +import { notFound } from "next/navigation"; +import { getCacheSolvesCount, getCacheUser } from "@/cache/user"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { fromNow } from "@/utils/time"; +import { ProfileButtons } from "./layout.client"; +import { Metadata } from "next"; +import { env } from "@/env.mjs"; +import { siteMetadataConfig } from "@repo/config"; + +export async function generateMetadata({ + params, +}: { + params: { username: string }; +}): Promise { + const username = decodeURIComponent(params.username).replace("@", ""); + const user = await getCacheUser(username); + + if (!user) + return { + title: `${username} profile`, + description: `View profile of ${username} on FrameGround`, + }; + const searchParams = new URLSearchParams(); + searchParams.set("name", user.name!); + searchParams.set("username", user.username!); + return { + metadataBase: new URL(env.HOST), + title: { + template: `${user.username}'s | %s`, + default: `${user.username}'s profile`, + }, + description: `View profile of ${user.username} on FrameGround`, + twitter: siteMetadataConfig.twitter, + openGraph: { + type: "website", + title: `${user.username}'s profile`, + description: `View profile of ${user.username} on FrameGround`, + url: `${env.HOST}/@${user.username}`, + images: [ + { + url: `${env.HOST}/api/og/user?${searchParams.toString()}`, + width: 1200, + height: 600, + alt: `${user.username}'s profile`, + }, + ], + }, + }; +} + +async function ProfileLayout({ + params, + children, +}: PropsWithChildren<{ + params: { username: string }; +}>) { + const username = decodeURIComponent(params.username).replace("@", ""); + + const user = await getCacheUser(username); + if (!user) notFound(); + const solves = await getCacheSolvesCount(user.id); + return ( + <> +
+
+
+ + + {username.toUpperCase()} + +
+
+
+ {user?.name} +
+ + @{user?.username} + +
+ + Joined {fromNow(new Date(user!.createdAt!))} + + +
+
+
+ {children} +
+
+
+ + ); +} + +export default ProfileLayout; diff --git a/apps/web/app/[username]/page.tsx b/apps/web/app/[username]/page.tsx index 4f43945..92b281a 100644 --- a/apps/web/app/[username]/page.tsx +++ b/apps/web/app/[username]/page.tsx @@ -1,17 +1,9 @@ -import { auth } from "@/auth"; +import { getCacheUser } from "@/cache/user"; import { Markdown } from "@/components/markdown"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button, buttonVariants } from "@/components/ui/button"; import { env } from "@/env.mjs"; -import { fromNow } from "@/utils/time"; import { siteMetadataConfig } from "@repo/config"; -import { prisma } from "@repo/db"; -import { Difficulty } from "@repo/db/types"; -import { AudioWaveform, BadgeCheck, CheckCheck } from "lucide-react"; import { Metadata } from "next"; -import Link from "next/link"; import { notFound } from "next/navigation"; -import { IoCog } from "react-icons/io5"; export const dynamic = "force-dynamic"; @@ -21,15 +13,7 @@ export async function generateMetadata({ params: { username: string }; }): Promise { const username = decodeURIComponent(params.username).replace("@", ""); - const user = await prisma.user.findFirst({ - where: { username: { equals: username, mode: "insensitive" } }, - select: { - username: true, - tracks: true, - name: true, - createdAt: true, - }, - }); + const user = await getCacheUser(username); if (!user) return { @@ -63,115 +47,27 @@ export async function generateMetadata({ async function ProfilePage({ params }: { params: { username: string } }) { const username = decodeURIComponent(params.username).replace("@", ""); - const session = await auth(); - const user = await prisma.user.findFirst({ - where: { username: { equals: username, mode: "insensitive" } }, - select: { - username: true, - tracks: true, - name: true, - createdAt: true, - bio: true, - id: true, - _count: { - select: { - solutions: true, - tracks: true, - }, - }, - }, - })!; + const user = await getCacheUser(username); if (!user) notFound(); - // this is required as one user can attempt same question multiple times - const solves = await prisma.solves.groupBy({ - by: ["type", "id"], - _count: true, - where: { - userId: user.id, - }, - }); return ( -
-
-
- - - {username.toUpperCase()} - -
-
-
- {user?.name} -
- - @{user?.username} - -
- - Joined {fromNow(new Date(user!.createdAt!))} - -
- - - - {session?.user?.username?.toLowerCase() === - username.toLowerCase() ? ( - - - Settings - - ) : null} -
-
-
-
-
-

- Bio -

-
-
- {user.bio ? ( - - ) : ( -

No bio yet!

- )} -
-
+ <> +
+

+ Bio +

+
+
+ {user.bio ? ( + + ) : ( +

No bio yet!

+ )}
-
+ ); } diff --git a/apps/web/app/settings/actions.ts b/apps/web/app/settings/actions.ts index 51a3d08..93f1912 100644 --- a/apps/web/app/settings/actions.ts +++ b/apps/web/app/settings/actions.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { prisma } from "@repo/db"; import { auth } from "@/auth"; +import { revalidateTag } from "next/cache"; const updateBioSchema = z.object({ bio: z.string().nullable(), @@ -29,6 +30,7 @@ export async function UpdateBio(prev: any, current: FormData) { bio: parsed.data.bio, }, }); + revalidateTag(`profile::${user!.username}`); return { message: "Profile Updated", }; diff --git a/apps/web/app/tracks/[track]/challenge/[challenge]/action.ts b/apps/web/app/tracks/[track]/challenge/[challenge]/action.ts index 3650518..2968012 100644 --- a/apps/web/app/tracks/[track]/challenge/[challenge]/action.ts +++ b/apps/web/app/tracks/[track]/challenge/[challenge]/action.ts @@ -92,6 +92,10 @@ export async function solveChallenge( } return solved; }); + revalidateTag(`user::challenges::${user?.username}`); + revalidateTag(`profile::${user?.username}`); + revalidateTag(`user::solves::${user?.id}`); + return { url: `/tracks/${_tx.track?.slug}/challenge/${_tx.slug}/solved`, }; diff --git a/apps/web/cache/user.ts b/apps/web/cache/user.ts new file mode 100644 index 0000000..b46efe6 --- /dev/null +++ b/apps/web/cache/user.ts @@ -0,0 +1,84 @@ +import { prisma } from "@repo/db"; +import { memoize } from "nextjs-better-unstable-cache"; + +export const getCacheUser = memoize( + async (username: string) => + await prisma.user.findFirst({ + where: { username: { equals: username, mode: "insensitive" } }, + select: { + username: true, + tracks: true, + name: true, + createdAt: true, + bio: true, + id: true, + _count: { + select: { + solutions: true, + tracks: true, + }, + }, + }, + })!, + { + revalidateTags: (username: string) => [`profile::${username}`], + log: ["verbose", "datacache", "dedupe"], + logid: "getCacheUser", + } +); + +export const getCacheSolvesCount = memoize( + async (userId: string) => + await prisma.$queryRaw< + { + solves_count: number; + }[] + >`select cast(count(*) as INTEGER) as solves_count from (select distinct("challengeId","userId") from "Solves" where type = 'accepted' and "userId" = ${userId}) as distinct_solves;`, + { + revalidateTags: (userId: string) => [`user::solves::${userId}`], + log: ["verbose", "datacache", "dedupe"], + logid: "getCacheSolvesCount", + } +); + +export const getCachedSolvedChallenges = memoize( + async (username: string) => + await prisma.challenge.findMany({ + where: { + solves: { + some: { + type: "accepted", + user: { + username: { + equals: username, + mode: "insensitive", + }, + }, + }, + }, + }, + select: { + label: true, + id: true, + solves: { + take: 1, + orderBy: [ + { + createdAt: "desc", + }, + ], + }, + track: { + select: { + slug: true, + }, + }, + slug: true, + }, + }), + { + revalidateTags: (username) => [`user::challenges::${username}`], + log: ["verbose", "datacache", "dedupe"], + logid: "getCachedSolvedChallenges", + } +); diff --git a/apps/web/components/ui/table.tsx b/apps/web/components/ui/table.tsx new file mode 100644 index 0000000..7f3502f --- /dev/null +++ b/apps/web/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}