Skip to content

Commit

Permalink
[web] add caching for user profile
Browse files Browse the repository at this point in the history
  • Loading branch information
PhantomKnight287 committed Feb 4, 2024
1 parent 04aa509 commit b13805b
Show file tree
Hide file tree
Showing 8 changed files with 466 additions and 123 deletions.
65 changes: 65 additions & 0 deletions apps/web/app/[username]/challenges/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div className="flex flex-col p-6 pb-0">
<h3 className="text-lg font-semibold leading-none tracking-tight">
Solved Challenges
</h3>
</div>
<div className="p-6">
{challenges.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Latest Submission</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{challenges.map((challenge) => (
<TableRow key={challenge.id}>
<TableCell className="font-medium">
<Link
href={`/track/${challenge.track!.slug}/challenge/${
challenge.slug
}`}
className="hover:underline"
>
{challenge.label}
</Link>
</TableCell>
<TableCell>
{fromNow(challenge.solves[0].createdAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</div>
</>
);
}

export default Challenges;
79 changes: 79 additions & 0 deletions apps/web/app/[username]/layout.client.tsx
Original file line number Diff line number Diff line change
@@ -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() ? (
<Link
className={buttonVariants({
variant: "outline",
className:
"rounded-xl text-left flex flex-row items-center !justify-start gap-2",
})}
href="/settings"
>
<IoCog size={24} />
Settings
</Link>
) : null}
</>
);
}

export default LayoutClientComponents;

export function ProfileButtons({
user,
username,
solves,
}: {
username: string;
user: NonNullable<Awaited<ReturnType<typeof getCacheUser>>>;
solves: NonNullable<Awaited<ReturnType<typeof getCacheSolvesCount>>>;
}) {
const pathname = usePathname()
.replaceAll(`/@${username}/`, "")
.replace("/", "");
return (
<div className="md:flex grid grid-cols-1 grid-rows-4 sm:grid-cols-2 sm:grid-rows-2 gap-4 md:w-full md:flex-col">
<Link
href={`/@${username}/challenges`}
className={buttonVariants({
variant: pathname === "challenges" ? "secondary" : "outline",
className:
"rounded-xl text-left flex flex-row items-center !justify-start gap-2 ",
})}
>
<BadgeCheck className="text-green-500" />
{solves?.[0]?.solves_count || 0}{" "}
{solves?.[0]?.solves_count <= 1 ? "Challenge" : "Challenges"} Conquered
</Link>
<Button
variant={"outline"}
className="rounded-xl text-left flex flex-row items-center justify-start gap-2"
>
<AudioWaveform className="text-blue-500" />
Enrolled in {user._count.tracks}{" "}
{user._count.tracks <= 1 ? "Track" : "Tracks"}
</Button>
<Button
variant={"outline"}
className="rounded-xl text-left flex flex-row items-center justify-start gap-2"
>
<CheckCheck className="text-purple-500" />
{user._count.solutions}{" "}
{user._count.solutions <= 1 ? "Solution" : "Solutions"} Posted
</Button>
<LayoutClientComponents username={username} />
</div>
);
}
96 changes: 96 additions & 0 deletions apps/web/app/[username]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<>
<div className="container">
<div className="flex flex-col gap-8 py-8 md:flex-row">
<div className="flex flex-col gap-4">
<Avatar className="h-32 w-32 rounded-3xl bg-cover bg-center bg-no-repeat md:h-64 md:w-64 mx-auto md:mx-0">
<AvatarImage src={`/github-avatar/${username}`} />
<AvatarFallback>{username.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col items-center gap-2 md:w-full md:items-start ">
<div className="flex gap-0 flex-col">
<div className="text-[2rem] font-bold [&amp;>small]:text-[0.7em] [&amp;>small]:dark:text-slate-400 [&amp;>small]:text-slate-600 font-default h-fit">
{user?.name}
</div>
<span className="text-lg text-muted-foreground">
@{user?.username}
</span>
</div>
<span className="text-muted-foreground tracking-tight mt-4">
Joined {fromNow(new Date(user!.createdAt!))}
</span>
<ProfileButtons solves={solves} user={user} username={username} />
</div>
</div>
<div className="bg-card text-card-foreground rounded-3xl border shadow-sm col-span-4 md:min-h-[calc(100vh_-_56px_-_6rem)] w-full">
{children}
</div>
</div>
</div>
</>
);
}

export default ProfileLayout;
Loading

0 comments on commit b13805b

Please sign in to comment.