From 06a5d46e1f35ef4cb16c2f975861f57a17fc88b4 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 25 Nov 2024 21:53:46 +0100 Subject: [PATCH] feat: complete profile page with profile edit, project likes and UI updates --- backend/database/src/index.ts | 180 ++++- backend/database/src/schema.ts | 74 ++- frontend/app/[username]/page.tsx | 46 +- .../dashboard/projectCard/dropdown.tsx | 2 +- .../dashboard/projectCard/index.tsx | 123 +++- frontend/components/dashboard/projects.tsx | 26 +- frontend/components/profile/index.tsx | 624 ++++++++++++------ frontend/components/profile/navbar.tsx | 9 +- frontend/lib/actions.ts | 93 +++ frontend/lib/types.ts | 4 +- 10 files changed, 888 insertions(+), 293 deletions(-) diff --git a/backend/database/src/index.ts b/backend/database/src/index.ts index b029045..ff3b095 100644 --- a/backend/database/src/index.ts +++ b/backend/database/src/index.ts @@ -5,7 +5,13 @@ import { z } from "zod" import { and, eq, sql } from "drizzle-orm" import * as schema from "./schema" -import { sandbox, user, usersToSandboxes } from "./schema" +import { + Sandbox, + sandbox, + sandboxLikes, + user, + usersToSandboxes, +} from "./schema" export interface Env { DB: D1Database @@ -18,6 +24,13 @@ export interface Env { // npm run generate // npx wrangler d1 execute d1-sandbox --local --file=./drizzle/ +interface SandboxWithLiked extends Sandbox { + liked: boolean +} + +interface UserResponse extends Omit { + sandbox: SandboxWithLiked[] +} export default { async fetch( @@ -258,33 +271,147 @@ export default { .get() return success + } else if (path === "/api/sandbox/like") { + if (method === "POST") { + const likeSchema = z.object({ + sandboxId: z.string(), + userId: z.string(), + }) + + try { + const body = await request.json() + const { sandboxId, userId } = likeSchema.parse(body) + + // Check if user has already liked + const existingLike = await db.query.sandboxLikes.findFirst({ + where: (likes, { and, eq }) => + and(eq(likes.sandboxId, sandboxId), eq(likes.userId, userId)), + }) + + if (existingLike) { + // Unlike + await db + .delete(sandboxLikes) + .where( + and( + eq(sandboxLikes.sandboxId, sandboxId), + eq(sandboxLikes.userId, userId) + ) + ) + + await db + .update(sandbox) + .set({ + likeCount: sql`${sandbox.likeCount} - 1`, + }) + .where(eq(sandbox.id, sandboxId)) + + return json({ + message: "Unlike successful", + liked: false, + }) + } else { + // Like + await db.insert(sandboxLikes).values({ + sandboxId, + userId, + createdAt: new Date(), + }) + + await db + .update(sandbox) + .set({ + likeCount: sql`${sandbox.likeCount} + 1`, + }) + .where(eq(sandbox.id, sandboxId)) + + return json({ + message: "Like successful", + liked: true, + }) + } + } catch (error) { + return new Response("Invalid request format", { status: 400 }) + } + } else if (method === "GET") { + const params = url.searchParams + const sandboxId = params.get("sandboxId") + const userId = params.get("userId") + + if (!sandboxId || !userId) { + return invalidRequest + } + + const like = await db.query.sandboxLikes.findFirst({ + where: (likes, { and, eq }) => + and(eq(likes.sandboxId, sandboxId), eq(likes.userId, userId)), + }) + + return json({ + liked: !!like, + }) + } else { + return methodNotAllowed + } } else if (path === "/api/user") { if (method === "GET") { const params = url.searchParams if (params.has("id")) { const id = params.get("id") as string + const res = await db.query.user.findFirst({ where: (user, { eq }) => eq(user.id, id), with: { sandbox: { orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)], + with: { + likes: true, + }, }, usersToSandboxes: true, }, }) + if (res) { + const transformedUser: UserResponse = { + ...res, + sandbox: res.sandbox.map( + (sb): SandboxWithLiked => ({ + ...sb, + liked: sb.likes.some((like) => like.userId === id), + }) + ), + } + return json(transformedUser) + } return json(res ?? {}) } else if (params.has("username")) { const username = params.get("username") as string + const userId = params.get("currentUserId") const res = await db.query.user.findFirst({ where: (user, { eq }) => eq(user.username, username), with: { sandbox: { orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)], + with: { + likes: true, + }, }, usersToSandboxes: true, }, }) + if (res) { + const transformedUser: UserResponse = { + ...res, + sandbox: res.sandbox.map( + (sb): SandboxWithLiked => ({ + ...sb, + liked: sb.likes.some((like) => like.userId === userId), + }) + ), + } + return json(transformedUser) + } return json(res ?? {}) } else { const res = await db.select().from(user).all() @@ -326,6 +453,57 @@ export default { await db.delete(user).where(eq(user.id, id)) return success } else return invalidRequest + } else if (method === "PUT") { + const updateUserSchema = z.object({ + id: z.string(), + name: z.string().optional(), + email: z.string().email().optional(), + username: z.string().optional(), + avatarUrl: z.string().optional(), + generations: z.number().optional(), + }) + + try { + const body = await request.json() + const validatedData = updateUserSchema.parse(body) + + const { id, username, ...updateData } = validatedData + + // If username is being updated, check for existing username + if (username) { + const existingUser = await db + .select() + .from(user) + .where(eq(user.username, username)) + .get() + if (existingUser && existingUser.id !== id) { + return json({ error: "Username already exists" }, { status: 409 }) + } + } + + const cleanUpdateData = { + ...updateData, + ...(username ? { username } : {}), + } + + const res = await db + .update(user) + .set(cleanUpdateData) + .where(eq(user.id, id)) + .returning() + .get() + + if (!res) { + return json({ error: "User not found" }, { status: 404 }) + } + + return json({ res }) + } catch (error) { + if (error instanceof z.ZodError) { + return json({ error: error.errors }, { status: 400 }) + } + return json({ error: "Internal server error" }, { status: 500 }) + } } else { return methodNotAllowed } diff --git a/backend/database/src/schema.ts b/backend/database/src/schema.ts index 5b28ca1..5d40a97 100644 --- a/backend/database/src/schema.ts +++ b/backend/database/src/schema.ts @@ -1,8 +1,8 @@ import { createId } from "@paralleldrive/cuid2" -import { relations } from "drizzle-orm" -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" -import { sql } from "drizzle-orm" +import { relations, sql } from "drizzle-orm" +import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core" +// #region Tables export const user = sqliteTable("user", { id: text("id") .$defaultFn(() => createId()) @@ -12,18 +12,14 @@ export const user = sqliteTable("user", { email: text("email").notNull(), username: text("username").notNull().unique(), avatarUrl: text("avatarUrl"), - createdAt: integer("createdAt", { mode: "timestamp_ms" }) - .default(sql`CURRENT_TIMESTAMP`), + createdAt: integer("createdAt", { mode: "timestamp_ms" }).default( + sql`CURRENT_TIMESTAMP` + ), generations: integer("generations").default(0), }) export type User = typeof user.$inferSelect -export const userRelations = relations(user, ({ many }) => ({ - sandbox: many(sandbox), - usersToSandboxes: many(usersToSandboxes), -})) - export const sandbox = sqliteTable("sandbox", { id: text("id") .$defaultFn(() => createId()) @@ -32,8 +28,9 @@ export const sandbox = sqliteTable("sandbox", { name: text("name").notNull(), type: text("type").notNull(), visibility: text("visibility", { enum: ["public", "private"] }), - createdAt: integer("createdAt", { mode: "timestamp_ms" }) - .default(sql`CURRENT_TIMESTAMP`), + createdAt: integer("createdAt", { mode: "timestamp_ms" }).default( + sql`CURRENT_TIMESTAMP` + ), userId: text("user_id") .notNull() .references(() => user.id), @@ -43,13 +40,23 @@ export const sandbox = sqliteTable("sandbox", { export type Sandbox = typeof sandbox.$inferSelect -export const sandboxRelations = relations(sandbox, ({ one, many }) => ({ - author: one(user, { - fields: [sandbox.userId], - references: [user.id], - }), - usersToSandboxes: many(usersToSandboxes), -})) +export const sandboxLikes = sqliteTable( + "sandbox_likes", + { + userId: text("user_id") + .notNull() + .references(() => user.id), + sandboxId: text("sandbox_id") + .notNull() + .references(() => sandbox.id), + createdAt: integer("createdAt", { mode: "timestamp_ms" }).default( + sql`CURRENT_TIMESTAMP` + ), + }, + (table) => ({ + pk: primaryKey({ columns: [table.sandboxId, table.userId] }), + }) +) export const usersToSandboxes = sqliteTable("users_to_sandboxes", { userId: text("userId") @@ -61,6 +68,33 @@ export const usersToSandboxes = sqliteTable("users_to_sandboxes", { sharedOn: integer("sharedOn", { mode: "timestamp_ms" }), }) +// #region Relations +export const userRelations = relations(user, ({ many }) => ({ + sandbox: many(sandbox), + usersToSandboxes: many(usersToSandboxes), + likes: many(sandboxLikes), +})) + +export const sandboxRelations = relations(sandbox, ({ one, many }) => ({ + author: one(user, { + fields: [sandbox.userId], + references: [user.id], + }), + usersToSandboxes: many(usersToSandboxes), + likes: many(sandboxLikes), +})) + +export const sandboxLikesRelations = relations(sandboxLikes, ({ one }) => ({ + user: one(user, { + fields: [sandboxLikes.userId], + references: [user.id], + }), + sandbox: one(sandbox, { + fields: [sandboxLikes.sandboxId], + references: [sandbox.id], + }), +})) + export const usersToSandboxesRelations = relations( usersToSandboxes, ({ one }) => ({ @@ -74,3 +108,5 @@ export const usersToSandboxesRelations = relations( }), }) ) + +// #endregion diff --git a/frontend/app/[username]/page.tsx b/frontend/app/[username]/page.tsx index 9b1d2d1..87e8ef4 100644 --- a/frontend/app/[username]/page.tsx +++ b/frontend/app/[username]/page.tsx @@ -1,7 +1,8 @@ import ProfilePage from "@/components/profile" import ProfileNavbar from "@/components/profile/navbar" -import { Sandbox, User } from "@/lib/types" +import { SandboxWithLiked, User } from "@/lib/types" import { currentUser } from "@clerk/nextjs" +import { notFound } from "next/navigation" export default async function Page({ params: { username: rawUsername }, @@ -9,11 +10,11 @@ export default async function Page({ params: { username: string } }) { const username = decodeURIComponent(rawUsername).replace("@", "") - const currentLoggedInUser = await currentUser() - console.log(username) - const [profileRespnse, dbUserResponse] = await Promise.all([ + const loggedInClerkUser = await currentUser() + + const [profileOwnerResponse, loggedInUserResponse] = await Promise.all([ fetch( - `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?username=${username}`, + `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?username=${username}¤tUserId=${loggedInClerkUser?.id}`, { headers: { Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, @@ -21,7 +22,7 @@ export default async function Page({ } ), fetch( - `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${currentLoggedInUser?.id}`, + `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${loggedInClerkUser?.id}`, { headers: { Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, @@ -30,30 +31,35 @@ export default async function Page({ ), ]) - const userProfile = (await profileRespnse.json()) as User - const dbUserData = (await dbUserResponse.json()) as User - const publicSandboxes: Sandbox[] = [] - const privateSandboxes: Sandbox[] = [] + const profileOwner = (await profileOwnerResponse.json()) as User + const loggedInUser = (await loggedInUserResponse.json()) as User - userProfile?.sandbox?.forEach((sandbox) => { + if (!Boolean(profileOwner?.id)) { + notFound() + } + const publicSandboxes: SandboxWithLiked[] = [] + const privateSandboxes: SandboxWithLiked[] = [] + + profileOwner?.sandbox?.forEach((sandbox) => { if (sandbox.visibility === "public") { - publicSandboxes.push(sandbox) + publicSandboxes.push(sandbox as SandboxWithLiked) } else if (sandbox.visibility === "private") { - privateSandboxes.push(sandbox) + privateSandboxes.push(sandbox as SandboxWithLiked) } }) - const hasCurrentUser = Boolean(dbUserData?.id) + + const isUserLoggedIn = Boolean(loggedInUser?.id) return ( -
- +
+ -
+ ) } diff --git a/frontend/components/dashboard/projectCard/dropdown.tsx b/frontend/components/dashboard/projectCard/dropdown.tsx index e75b493..207c9f8 100644 --- a/frontend/components/dashboard/projectCard/dropdown.tsx +++ b/frontend/components/dashboard/projectCard/dropdown.tsx @@ -26,7 +26,7 @@ export default function ProjectCardDropdown({ e.preventDefault() e.stopPropagation() }} - className="h-6 w-6 flex items-center justify-center transition-colors bg-transparent hover:bg-muted-foreground/25 rounded-sm outline-foreground" + className="h-6 w-6 z-10 flex items-center justify-center transition-colors bg-transparent hover:bg-muted-foreground/25 rounded-sm outline-foreground" > diff --git a/frontend/components/dashboard/projectCard/index.tsx b/frontend/components/dashboard/projectCard/index.tsx index c2c2886..4c1ec42 100644 --- a/frontend/components/dashboard/projectCard/index.tsx +++ b/frontend/components/dashboard/projectCard/index.tsx @@ -1,13 +1,26 @@ "use client" +import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" +import { toggleLike } from "@/lib/actions" import { projectTemplates } from "@/lib/data" import { Sandbox } from "@/lib/types" +import { cn } from "@/lib/utils" +import { useUser } from "@clerk/nextjs" import { AnimatePresence, motion } from "framer-motion" import { Clock, Eye, Globe, Heart, Lock } from "lucide-react" import Image from "next/image" +import Link from "next/link" import { useRouter } from "next/navigation" -import { memo, useEffect, useMemo, useState } from "react" +import { + memo, + MouseEventHandler, + useEffect, + useMemo, + useOptimistic, + useState, + useTransition, +} from "react" import ProjectCardDropdown from "./dropdown" import { CanvasRevealEffect } from "./revealEffect" @@ -18,6 +31,7 @@ type BaseProjectCardProps = { visibility: "public" | "private" createdAt: Date likeCount: number + liked?: boolean viewCount: number } @@ -59,16 +73,19 @@ const formatDate = (date: Date): string => { const ProjectMetadata = memo( ({ + id, visibility, createdAt, likeCount, + liked, viewCount, }: Pick< BaseProjectCardProps, - "visibility" | "createdAt" | "likeCount" | "viewCount" + "visibility" | "createdAt" | "likeCount" | "liked" | "viewCount" | "id" >) => { + const { user } = useUser() const [date, setDate] = useState() - + const Icon = visibility === "private" ? Lock : Globe useEffect(() => { setDate(formatDate(new Date(createdAt))) }, [createdAt]) @@ -76,23 +93,23 @@ const ProjectMetadata = memo( return (
-
- {visibility === "private" ? ( - <> - Private - - ) : ( - <> - Public - - )} +
+ + + {visibility === "private" ? "Private" : "Public"} +
-
-
- {date} +
+
+ {date}
- +
@@ -102,6 +119,63 @@ const ProjectMetadata = memo( ProjectMetadata.displayName = "ProjectMetadata" +interface LikeButtonProps { + sandboxId: string + userId: string | null + initialLikeCount: number + initialIsLiked: boolean +} + +export function LikeButton({ + sandboxId, + userId, + initialLikeCount, + initialIsLiked, +}: LikeButtonProps) { + // Optimistic state for like status and count + const [{ isLiked, likeCount }, optimisticUpdateLike] = useOptimistic( + { isLiked: initialIsLiked, likeCount: initialLikeCount }, + (state, optimisticValue: boolean) => { + return { + isLiked: optimisticValue, + likeCount: state.likeCount + (optimisticValue ? 1 : -1), + } + } + ) + + const [isPending, startTransition] = useTransition() + + const handleLike: MouseEventHandler = async (e) => { + e.stopPropagation() // Prevent click event from bubbling up which leads to navigation to /code/:id + if (!userId) return + + startTransition(async () => { + const newLikeState = !isLiked + try { + optimisticUpdateLike(newLikeState) + await toggleLike(sandboxId, userId) + } catch (error) { + console.log("error", error) + optimisticUpdateLike(!newLikeState) + } + }) + } + + return ( + + ) +} function ProjectCardComponent({ id, name, @@ -150,7 +224,11 @@ function ProjectCardComponent({ className={` group/canvas-card p-4 h-48 flex flex-col justify-between items-start hover:border-muted-foreground/50 relative overflow-hidden transition-all - ${props.isAuthenticated && props.deletingId === id ? "opacity-50" : ""} + ${ + props.isAuthenticated && props.deletingId === id + ? "opacity-50 pointer-events-none cursor-events-none" + : "cursor-pointer" + } `} > @@ -178,9 +256,12 @@ function ProjectCardComponent({ width={20} height={20} /> -
+ {name} -
+ {props.isAuthenticated && ( ) diff --git a/frontend/components/dashboard/projects.tsx b/frontend/components/dashboard/projects.tsx index f06746a..14618fa 100644 --- a/frontend/components/dashboard/projects.tsx +++ b/frontend/components/dashboard/projects.tsx @@ -2,8 +2,6 @@ import { deleteSandbox, updateSandbox } from "@/lib/actions" import { Sandbox } from "@/lib/types" -import { cn } from "@/lib/utils" -import Link from "next/link" import { useEffect, useMemo, useState } from "react" import { toast } from "sonner" import ProjectCard from "./projectCard" @@ -71,24 +69,14 @@ export default function DashboardProjects({ } } return ( - - - + onVisibilityChange={onVisibilityChange} + onDelete={onDelete} + deletingId={deletingId} + isAuthenticated + {...sandbox} + /> ) })}
diff --git a/frontend/components/profile/index.tsx b/frontend/components/profile/index.tsx index 632ee9e..4246da5 100644 --- a/frontend/components/profile/index.tsx +++ b/frontend/components/profile/index.tsx @@ -1,5 +1,6 @@ "use client" +import NewProjectModal from "@/components/dashboard/newProject" import ProjectCard from "@/components/dashboard/projectCard/" import { Button } from "@/components/ui/button" import { @@ -13,64 +14,118 @@ import { HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card" +import { Label } from "@/components/ui/label" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { deleteSandbox, updateSandbox } from "@/lib/actions" +import { deleteSandbox, updateSandbox, updateUser } from "@/lib/actions" import { MAX_FREE_GENERATION } from "@/lib/constant" -import { Sandbox, User } from "@/lib/types" -import { cn } from "@/lib/utils" -import { Heart, LucideIcon, Package2, PlusCircle, Sparkles } from "lucide-react" -import Link from "next/link" -import { useMemo, useState } from "react" +import { SandboxWithLiked, User } from "@/lib/types" +import { useUser } from "@clerk/nextjs" +import { + Edit, + Heart, + Info, + Loader2, + LucideIcon, + Package2, + PlusCircle, + Sparkles, + X, +} from "lucide-react" +import { useRouter } from "next/navigation" +import { Fragment, useCallback, useEffect, useMemo, useState } from "react" +import { useFormState, useFormStatus } from "react-dom" import { toast } from "sonner" import Avatar from "../ui/avatar" import { Badge } from "../ui/badge" +import { Input } from "../ui/input" import { Progress } from "../ui/progress" +// #region Profile Page export default function ProfilePage({ publicSandboxes, privateSandboxes, - user, - currentUser, + profileOwner, + loggedInUser, }: { - publicSandboxes: Sandbox[] - privateSandboxes: Sandbox[] - user: User - currentUser: User | null + publicSandboxes: SandboxWithLiked[] + privateSandboxes: SandboxWithLiked[] + profileOwner: User + loggedInUser: User | null }) { - const [deletingId, setDeletingId] = useState("") - const isLoggedIn = Boolean(currentUser) - const hasPublicSandboxes = publicSandboxes.length > 0 - const hasPrivateSandboxes = privateSandboxes.length > 0 + const isOwnProfile = profileOwner.id === loggedInUser?.id - const onVisibilityChange = useMemo( - () => async (sandbox: Pick) => { - const newVisibility = - sandbox.visibility === "public" ? "private" : "public" - toast(`Project ${sandbox.name} is now ${newVisibility}.`) - await updateSandbox({ - id: sandbox.id, - visibility: newVisibility, - }) - }, - [] - ) - - const onDelete = useMemo( - () => async (sandbox: Pick) => { - setDeletingId(sandbox.id) - toast(`Project ${sandbox.name} deleted.`) - await deleteSandbox(sandbox.id) - setDeletingId("") - }, - [] - ) - const stats = useMemo(() => { - const allSandboxes = isLoggedIn + const sandboxes = useMemo(() => { + const allSandboxes = isOwnProfile ? [...publicSandboxes, ...privateSandboxes] : publicSandboxes - const totalSandboxes = allSandboxes.length - const totalLikes = allSandboxes.reduce( + return allSandboxes + }, [isOwnProfile, publicSandboxes, privateSandboxes]) + + return ( + <> +
+
+ +
+
+ +
+
+ + ) +} +// #endregion + +// #region Profile Card +function ProfileCard({ + name, + username, + avatarUrl, + sandboxes, + joinedDate, + generations, + isOwnProfile, +}: { + name: string + username: string + avatarUrl: string | null + sandboxes: SandboxWithLiked[] + joinedDate: Date + generations?: number + isOwnProfile: boolean +}) { + const { user } = useUser() + const router = useRouter() + const [isEditing, setIsEditing] = useState(false) + const [formState, formAction] = useFormState(updateUser, {}) + const joinedAt = useMemo(() => { + const date = new Date(joinedDate).toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }) + return `Joined ${date}` + }, [joinedDate]) + const toggleEdit = useCallback(() => { + setIsEditing((s) => !s) + }, []) + const stats = useMemo(() => { + const totalSandboxes = sandboxes.length + const totalLikes = sandboxes.reduce( (sum, sandbox) => sum + sandbox.likeCount, 0 ) @@ -80,160 +135,299 @@ export default function ProfilePage({ totalSandboxes === 1 ? "1 sandbox" : `${totalSandboxes} sandboxes`, likes: totalLikes === 1 ? "1 like" : `${totalLikes} likes`, } - }, [isLoggedIn, publicSandboxes, privateSandboxes]) - const joinDate = useMemo( - () => - new Date(user.createdAt).toLocaleDateString("en-US", { - month: "long", - year: "numeric", - }), - [user.createdAt] - ) + }, [sandboxes]) + useEffect(() => { + if ("message" in formState) { + toast.success(formState.message as String) + toggleEdit() + if ("newRoute" in formState && typeof formState.newRoute === "string") { + router.replace(formState.newRoute) + } + } + if ("error" in formState) { + const error = formState.error + if (typeof error === "string") { + toast.error(error) + } else { + toast.error("An Error Occured") + } + } + }, [formState]) return ( - <> -
-
- - - - - {user.name} - {`@${user.username}`} -
- - -
-
-

- {`Joined ${joinDate}`} -

- {isLoggedIn && } -
-
-
-
-
- - - Public - {isLoggedIn && Private} - - - {hasPublicSandboxes ? ( -
- {publicSandboxes.map((sandbox) => { - return ( - - {isLoggedIn ? ( - - ) : ( - - )} - - ) - })} -
- ) : ( - - )} -
- {isLoggedIn && ( - - {hasPrivateSandboxes ? ( -
- {privateSandboxes.map((sandbox) => ( - - - - ))} -
- ) : ( - - )} -
- )} -
-
-
- - ) -} - -function EmptyState({ - title, - description, - isLoggedIn, -}: { - title: string - description: string - isLoggedIn: boolean -}) { - return ( - - - {title} - {description} - {isLoggedIn && ( - )} + + + + {!isEditing ? ( +
+ {name} + {`@${username}`} +
+ ) : ( +
+ + +
+ + +
+ +
+ +
+ + + @ + +
+
+ + + + )} + {!isEditing && ( + <> +
+ + +
+
+

{joinedAt}

+ {typeof generations === "number" && ( + + )} +
+ + )} +
) } +function SubmitButton() { + const { pending } = useFormStatus() + + return ( + + ) +} +// #endregion + +// #region Sandboxes Panel +function SandboxesPanel({ + publicSandboxes, + privateSandboxes, + isOwnProfile, +}: { + publicSandboxes: SandboxWithLiked[] + privateSandboxes: SandboxWithLiked[] + isOwnProfile: boolean +}) { + const [deletingId, setDeletingId] = useState("") + const hasPublicSandboxes = publicSandboxes.length > 0 + const hasPrivateSandboxes = privateSandboxes.length > 0 + + const onVisibilityChange = useMemo( + () => + async (sandbox: Pick) => { + const newVisibility = + sandbox.visibility === "public" ? "private" : "public" + toast(`Project ${sandbox.name} is now ${newVisibility}.`) + await updateSandbox({ + id: sandbox.id, + visibility: newVisibility, + }) + }, + [] + ) + + const onDelete = useMemo( + () => async (sandbox: Pick) => { + setDeletingId(sandbox.id) + toast(`Project ${sandbox.name} deleted.`) + await deleteSandbox(sandbox.id) + setDeletingId("") + }, + [] + ) + if (!isOwnProfile) { + return ( +
+ {hasPublicSandboxes ? ( + <> +

Sandboxes

+
+ {publicSandboxes.map((sandbox) => { + return ( + + {isOwnProfile ? ( + + ) : ( + + )} + + ) + })} +
+ + ) : ( + + )} +
+ ) + } + return ( + + + Public + Private + + + {hasPublicSandboxes ? ( +
+ {publicSandboxes.map((sandbox) => { + return ( + + {isOwnProfile ? ( + + ) : ( + + )} + + ) + })} +
+ ) : ( + + )} +
+ + {hasPrivateSandboxes ? ( +
+ {privateSandboxes.map((sandbox) => ( + + ))} +
+ ) : ( + + )} +
+
+ ) +} +// #endregion + +// #region Empty State +function EmptyState({ + type, + isOwnProfile, +}: { + type: "public" | "private" + isOwnProfile: boolean +}) { + const [newProjectModalOpen, setNewProjectModalOpen] = useState(false) + + const text = useMemo(() => { + let title: string + let description: string + switch (type) { + case "public": + title = "No public sandboxes yet" + description = isOwnProfile + ? "Create your first public sandbox to share your work with the world!" + : "user has no public sandboxes" + + case "private": + title = "No private sandboxes yet" + description = isOwnProfile + ? "Create your first private sandbox to start working on your personal projects!" + : "user has no private sandboxes" + } + return { + title, + description, + } + }, [type, isOwnProfile]) + const openModal = useCallback(() => setNewProjectModalOpen(true), []) + return ( + <> + + + {text.title} + {text.description} + {isOwnProfile && ( + + )} + + + + ) +} +// #endregion + +// #region StatsItem interface StatsItemProps { icon: LucideIcon label: string @@ -245,31 +439,39 @@ const StatsItem = ({ icon: Icon, label }: StatsItemProps) => ( {label}
) +// #endregion -const SubscriptionBadge = ({ user }: { user: User }) => { +// #region Sub Badge +const SubscriptionBadge = ({ generations }: { generations: number }) => { return ( - - - - Free - - - -
-
- AI Generations - {`${user.generations} / ${MAX_FREE_GENERATION}`} +
+ + Free + + + + + + +
+
+ AI Generations + {`${generations} / ${MAX_FREE_GENERATION}`} +
+
- -
- - - + + + +
) } +// #endregion diff --git a/frontend/components/profile/navbar.tsx b/frontend/components/profile/navbar.tsx index e4e7d07..f7e8648 100644 --- a/frontend/components/profile/navbar.tsx +++ b/frontend/components/profile/navbar.tsx @@ -4,6 +4,7 @@ import UserButton from "@/components/ui/userButton" import { User } from "@/lib/types" import Image from "next/image" import Link from "next/link" +import { Button } from "../ui/button" export default function ProfileNavbar({ userData }: { userData: User }) { return ( @@ -19,7 +20,13 @@ export default function ProfileNavbar({ userData }: { userData: User }) {
- {Boolean(userData?.id) ? : null} + {Boolean(userData?.id) ? ( + + ) : ( + + + + )}
) diff --git a/frontend/lib/actions.ts b/frontend/lib/actions.ts index be1d357..5d63a28 100644 --- a/frontend/lib/actions.ts +++ b/frontend/lib/actions.ts @@ -1,6 +1,7 @@ "use server" import { revalidatePath } from "next/cache" +import { z } from "zod" export async function createSandbox(body: { type: string @@ -91,3 +92,95 @@ export async function unshareSandbox(sandboxId: string, userId: string) { revalidatePath(`/code/${sandboxId}`) } + +export async function toggleLike(sandboxId: string, userId: string) { + await fetch( + `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/sandbox/like`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, + }, + body: JSON.stringify({ sandboxId, userId }), + } + ) + revalidatePath(`/[username]`, "page") + revalidatePath(`/dashboard`, "page") +} + +const UpdateErrorSchema = z.object({ + error: z + .union([ + z.string(), + z.array( + z.object({ + path: z.array(z.string()), + message: z.string(), + }) + ), + ]) + .optional(), +}) + +export async function updateUser(prevState: any, formData: FormData) { + const data = Object.fromEntries(formData) + + const schema = z.object({ + id: z.string(), + username: z.string(), + oldUsername: z.string(), + name: z.string(), + }) + console.log(data) + + try { + const validatedData = schema.parse(data) + + const changedUsername = validatedData.username !== validatedData.oldUsername + const res = await fetch( + `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, + }, + body: JSON.stringify({ + id: validatedData.id, + username: data.username ?? undefined, + name: data.name ?? undefined, + }), + } + ) + + const responseData = await res.json() + + // Validate the response using our error schema + const parseResult = UpdateErrorSchema.safeParse(responseData) + + if (!parseResult.success) { + return { error: "Unexpected error occurred" } + } + + if (parseResult.data.error) { + return parseResult.data + } + + if (changedUsername) { + const newRoute = `/@${validatedData.username}` + return { message: "Successfully updated", newRoute } + } + revalidatePath(`/[username]`, "page") + return { message: "Successfully updated" } + } catch (error) { + if (error instanceof z.ZodError) { + console.log(error) + return { + error: error.errors?.[0].message, + } + } + + return { error: "An unexpected error occurred" } + } +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 32f3246..9f588c1 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -23,7 +23,9 @@ export type Sandbox = { viewCount: number usersToSandboxes: UsersToSandboxes[] } - +export type SandboxWithLiked = Sandbox & { + liked: boolean +} export type UsersToSandboxes = { userId: string sandboxId: string