feat: complete profile page with profile edit, project likes and UI updates

This commit is contained in:
Hamzat Victor 2024-11-25 21:53:46 +01:00
parent 105eab9bad
commit 06a5d46e1f
10 changed files with 888 additions and 293 deletions

View File

@ -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/<FILE>
interface SandboxWithLiked extends Sandbox {
liked: boolean
}
interface UserResponse extends Omit<schema.User, "sandbox"> {
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
}

View File

@ -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

View File

@ -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}&currentUserId=${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 (
<div className="">
<ProfileNavbar userData={dbUserData} />
<section>
<ProfileNavbar userData={loggedInUser} />
<ProfilePage
publicSandboxes={publicSandboxes}
privateSandboxes={
userProfile?.id === dbUserData.id ? privateSandboxes : []
profileOwner?.id === loggedInUser.id ? privateSandboxes : []
}
user={userProfile}
currentUser={hasCurrentUser ? dbUserData : null}
profileOwner={profileOwner}
loggedInUser={isUserLoggedIn ? loggedInUser : null}
/>
</div>
</section>
)
}

View File

@ -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"
>
<Ellipsis className="w-4 h-4" />
</DropdownMenuTrigger>

View File

@ -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<string>()
const Icon = visibility === "private" ? Lock : Globe
useEffect(() => {
setDate(formatDate(new Date(createdAt)))
}, [createdAt])
@ -76,23 +93,23 @@ const ProjectMetadata = memo(
return (
<div className="flex flex-col text-muted-foreground space-y-2 text-sm z-10">
<div className="flex items-center justify-between">
<div className="flex items-center">
{visibility === "private" ? (
<>
<Lock className="size-4 mr-2" /> Private
</>
) : (
<>
<Globe className="size-4 mr-2" /> Public
</>
)}
<div className="flex items-center gap-2">
<Icon className="size-4" />
<span className="text-xs">
{visibility === "private" ? "Private" : "Public"}
</span>
</div>
</div>
<div className="flex gap-4">
<div className="flex items-center">
<Clock className="size-4 mr-2" /> {date}
<div className="flex gap-3">
<div className="flex items-center gap-2">
<Clock className="size-4" /> <span className="text-xs">{date}</span>
</div>
<StatItem icon={Heart} value={likeCount} />
<LikeButton
sandboxId={id}
initialIsLiked={!!liked}
initialLikeCount={likeCount}
userId={user?.id ?? null}
/>
<StatItem icon={Eye} value={viewCount} />
</div>
</div>
@ -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<HTMLButtonElement> = 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 (
<Button
variant="ghost"
size="sm"
disabled={!userId || isPending}
onClick={handleLike}
className="gap-1 px-1 rounded-full"
>
<Heart
className={cn("size-4", isLiked ? "stroke-red-500 fill-red-500" : "")}
/>
<span className="text-xs">{likeCount}</span>
</Button>
)
}
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"
}
`}
>
<AnimatePresence>
@ -178,9 +256,12 @@ function ProjectCardComponent({
width={20}
height={20}
/>
<div className="font-medium static whitespace-nowrap w-full text-ellipsis overflow-hidden">
<Link
href={`/code/${id}`}
className="font-medium static whitespace-nowrap w-full text-ellipsis overflow-hidden before:content-[''] before:absolute before:z-0 before:top-0 before:left-0 before:w-full before:h-full before:rounded-xl"
>
{name}
</div>
</Link>
{props.isAuthenticated && (
<ProjectCardDropdown
onVisibilityChange={handleVisibilityChange}
@ -195,6 +276,8 @@ function ProjectCardComponent({
createdAt={createdAt}
likeCount={likeCount}
viewCount={viewCount}
id={id}
liked={props.liked}
/>
</Card>
)

View File

@ -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 (
<Link
key={sandbox.id}
href={`/code/${sandbox.id}`}
className={cn(
"transition-all focus-visible:outline-none focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring rounded-lg",
deletingId === sandbox.id
? "pointer-events-none opacity-50 cursor-events-none"
: "cursor-pointer"
)}
>
<ProjectCard
key={sandbox.id}
onVisibilityChange={onVisibilityChange}
onDelete={onDelete}
deletingId={deletingId}
isAuthenticated
{...sandbox}
/>
</Link>
)
})}
</div>

View File

@ -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,37 +14,259 @@ 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 isOwnProfile = profileOwner.id === loggedInUser?.id
const sandboxes = useMemo(() => {
const allSandboxes = isOwnProfile
? [...publicSandboxes, ...privateSandboxes]
: publicSandboxes
return allSandboxes
}, [isOwnProfile, publicSandboxes, privateSandboxes])
return (
<>
<div className="container mx-auto p-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-1">
<ProfileCard
name={profileOwner.name}
username={profileOwner.username}
avatarUrl={profileOwner.avatarUrl}
sandboxes={sandboxes}
joinedDate={profileOwner.createdAt}
generations={isOwnProfile ? loggedInUser.generations : undefined}
isOwnProfile={isOwnProfile}
/>
</div>
<div className="md:col-span-2">
<SandboxesPanel
{...{
publicSandboxes,
privateSandboxes,
isOwnProfile,
}}
/>
</div>
</div>
</>
)
}
// #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
)
return {
sandboxes:
totalSandboxes === 1 ? "1 sandbox" : `${totalSandboxes} sandboxes`,
likes: totalLikes === 1 ? "1 like" : `${totalLikes} likes`,
}
}, [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 (
<Card className="mb-6 md:mb-0 sticky top-6">
{isOwnProfile && (
<Button
onClick={toggleEdit}
aria-label={isEditing ? "close edit form" : "open edit form"}
size="smIcon"
variant="secondary"
className="rounded-full absolute top-2 right-2"
>
{isEditing ? <X className="size-4" /> : <Edit className="size-4" />}
</Button>
)}
<CardContent className="flex flex-col gap-4 items-center pt-6">
<Avatar name={name} avatarUrl={avatarUrl} className="size-36" />
{!isEditing ? (
<div className="space-y-2">
<CardTitle className="text-2xl text-center">{name}</CardTitle>
<CardDescription className="text-center">{`@${username}`}</CardDescription>
</div>
) : (
<form action={formAction} className="flex flex-col gap-2">
<Input
name="id"
placeholder="ID"
className="hidden "
value={user?.id}
/>
<Input
name="oldUsername"
placeholder="ID"
className="hidden "
value={user?.username ?? undefined}
/>
<div className="space-y-1">
<Label htmlFor="input-name">Name</Label>
<Input
id="input-name"
name="name"
placeholder="Name"
defaultValue={name}
/>
</div>
<div className="space-y-1">
<Label htmlFor="input-username">User name</Label>
<div className="relative">
<Input
id="input-username"
className="peer ps-6"
type="text"
name="username"
placeholder="Username"
defaultValue={username}
/>
<span className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-2 text-sm text-muted-foreground peer-disabled:opacity-50">
@
</span>
</div>
</div>
<SubmitButton />
</form>
)}
{!isEditing && (
<>
<div className="flex gap-6">
<StatsItem icon={Package2} label={stats.sandboxes} />
<StatsItem icon={Heart} label={stats.likes} />
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-xs text-muted-foreground">{joinedAt}</p>
{typeof generations === "number" && (
<SubscriptionBadge generations={generations} />
)}
</div>
</>
)}
</CardContent>
</Card>
)
}
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button size="sm" type="submit" className="w-full mt-2" disabled={pending}>
{pending && <Loader2 className="animate-spin mr-2 h-4 w-4" />}
Save
</Button>
)
}
// #endregion
// #region Sandboxes Panel
function SandboxesPanel({
publicSandboxes,
privateSandboxes,
isOwnProfile,
}: {
publicSandboxes: SandboxWithLiked[]
privateSandboxes: SandboxWithLiked[]
isOwnProfile: boolean
}) {
const [deletingId, setDeletingId] = useState<string>("")
const isLoggedIn = Boolean(currentUser)
const hasPublicSandboxes = publicSandboxes.length > 0
const hasPrivateSandboxes = privateSandboxes.length > 0
const onVisibilityChange = useMemo(
() => async (sandbox: Pick<Sandbox, "id" | "name" | "visibility">) => {
() =>
async (sandbox: Pick<SandboxWithLiked, "id" | "name" | "visibility">) => {
const newVisibility =
sandbox.visibility === "public" ? "private" : "public"
toast(`Project ${sandbox.name} is now ${newVisibility}.`)
@ -56,7 +279,7 @@ export default function ProfilePage({
)
const onDelete = useMemo(
() => async (sandbox: Pick<Sandbox, "id" | "name">) => {
() => async (sandbox: Pick<SandboxWithLiked, "id" | "name">) => {
setDeletingId(sandbox.id)
toast(`Project ${sandbox.name} deleted.`)
await deleteSandbox(sandbox.id)
@ -64,81 +287,17 @@ export default function ProfilePage({
},
[]
)
const stats = useMemo(() => {
const allSandboxes = isLoggedIn
? [...publicSandboxes, ...privateSandboxes]
: publicSandboxes
const totalSandboxes = allSandboxes.length
const totalLikes = allSandboxes.reduce(
(sum, sandbox) => sum + sandbox.likeCount,
0
)
return {
sandboxes:
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]
)
if (!isOwnProfile) {
return (
<>
<div className="container mx-auto p-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-1">
<Card className="mb-6 md:mb-0 sticky top-6">
<CardContent className="flex flex-col gap-3 items-center pt-6">
<Avatar
name={user.name}
avatarUrl={user.avatarUrl}
className="size-36"
/>
<CardTitle className="text-2xl">{user.name}</CardTitle>
<CardDescription>{`@${user.username}`}</CardDescription>
<div className="flex gap-6">
<StatsItem icon={Package2} label={stats.sandboxes} />
<StatsItem icon={Heart} label={stats.likes} />
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-xs text-muted-foreground">
{`Joined ${joinDate}`}
</p>
{isLoggedIn && <SubscriptionBadge user={currentUser!} />}
</div>
</CardContent>
</Card>
</div>
<div className="md:col-span-2">
<Tabs defaultValue="public">
<TabsList className="mb-4">
<TabsTrigger value="public">Public</TabsTrigger>
{isLoggedIn && <TabsTrigger value="private">Private</TabsTrigger>}
</TabsList>
<TabsContent value="public">
<div className="">
{hasPublicSandboxes ? (
<>
<h2 className="font-semibold text-xl mb-4">Sandboxes</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{publicSandboxes.map((sandbox) => {
return (
<Link
key={sandbox.id}
href={`/code/${sandbox.id}`}
className={cn(
"transition-all focus-visible:outline-none focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring rounded-lg",
deletingId === sandbox.id
? "pointer-events-none opacity-50 cursor-events-none"
: "cursor-pointer"
)}
>
{isLoggedIn ? (
<Fragment key={sandbox.id}>
{isOwnProfile ? (
<ProjectCard
onVisibilityChange={onVisibilityChange}
onDelete={onDelete}
@ -149,37 +308,30 @@ export default function ProfilePage({
) : (
<ProjectCard isAuthenticated={false} {...sandbox} />
)}
</Link>
</Fragment>
)
})}
</div>
</>
) : (
<EmptyState
title="No public sandboxes yet"
description={
isLoggedIn
? "Create your first public sandbox to share your work with the world!"
: "Login to create public sandboxes"
<EmptyState type="private" isOwnProfile={isOwnProfile} />
)}
</div>
)
}
isLoggedIn={isLoggedIn}
/>
)}
</TabsContent>
{isLoggedIn && (
<TabsContent value="private">
{hasPrivateSandboxes ? (
return (
<Tabs defaultValue="public">
<TabsList className="mb-4">
<TabsTrigger value="public">Public</TabsTrigger>
<TabsTrigger value="private">Private</TabsTrigger>
</TabsList>
<TabsContent value="public">
{hasPublicSandboxes ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{privateSandboxes.map((sandbox) => (
<Link
key={sandbox.id}
href={`/code/${sandbox.id}`}
className={cn(
"transition-all focus-visible:outline-none focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring rounded-lg",
deletingId === sandbox.id
? "pointer-events-none opacity-50 cursor-events-none"
: "cursor-pointer"
)}
>
{publicSandboxes.map((sandbox) => {
return (
<Fragment key={sandbox.id}>
{isOwnProfile ? (
<ProjectCard
onVisibilityChange={onVisibilityChange}
onDelete={onDelete}
@ -187,53 +339,95 @@ export default function ProfilePage({
isAuthenticated
{...sandbox}
/>
</Link>
) : (
<ProjectCard isAuthenticated={false} {...sandbox} />
)}
</Fragment>
)
})}
</div>
) : (
<EmptyState type="public" isOwnProfile={isOwnProfile} />
)}
</TabsContent>
<TabsContent value="private">
{hasPrivateSandboxes ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{privateSandboxes.map((sandbox) => (
<ProjectCard
key={sandbox.id}
onVisibilityChange={onVisibilityChange}
onDelete={onDelete}
deletingId={deletingId}
isAuthenticated
{...sandbox}
/>
))}
</div>
) : (
<EmptyState
title="No private sandboxes yet"
description={
isLoggedIn
? "Create your first private sandbox to start working on your personal projects!"
: "Login to create private sandboxes"
}
isLoggedIn={isLoggedIn}
/>
<EmptyState type="private" isOwnProfile={isOwnProfile} />
)}
</TabsContent>
)}
</Tabs>
</div>
</div>
</>
)
}
// #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,
isLoggedIn,
}: {
title: string
description: string
isLoggedIn: boolean
}) {
}
}, [type, isOwnProfile])
const openModal = useCallback(() => setNewProjectModalOpen(true), [])
return (
<>
<Card className="flex flex-col items-center justify-center p-6 text-center h-[300px]">
<PlusCircle className="h-12 w-12 text-muted-foreground mb-4" />
<CardTitle className="text-xl mb-2">{title}</CardTitle>
<CardDescription className="mb-4">{description}</CardDescription>
{isLoggedIn && (
<Button>
<CardTitle className="text-xl mb-2">{text.title}</CardTitle>
<CardDescription className="mb-4">{text.description}</CardDescription>
{isOwnProfile && (
<Button onClick={openModal}>
<PlusCircle className="h-4 w-4 mr-2" />
Create Sandbox
</Button>
)}
</Card>
<NewProjectModal
open={newProjectModalOpen}
setOpen={setNewProjectModalOpen}
/>
</>
)
}
// #endregion
// #region StatsItem
interface StatsItemProps {
icon: LucideIcon
label: string
@ -245,23 +439,29 @@ const StatsItem = ({ icon: Icon, label }: StatsItemProps) => (
<span className="text-sm text-muted-foreground">{label}</span>
</div>
)
// #endregion
const SubscriptionBadge = ({ user }: { user: User }) => {
// #region Sub Badge
const SubscriptionBadge = ({ generations }: { generations: number }) => {
return (
<HoverCard>
<HoverCardTrigger>
<Badge variant="secondary" className="text-xs cursor-pointer">
<div className="flex gap-2 items-center">
<Badge variant="secondary" className="text-sm cursor-pointer">
Free
</Badge>
<HoverCard>
<HoverCardTrigger>
<Button variant="ghost" size="smIcon">
<Info size={20} />
</Button>
</HoverCardTrigger>
<HoverCardContent>
<div className="w-full space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">AI Generations</span>
<span>{`${user.generations} / ${MAX_FREE_GENERATION}`}</span>
<span>{`${generations} / ${MAX_FREE_GENERATION}`}</span>
</div>
<Progress
value={user?.generations!}
value={generations}
max={MAX_FREE_GENERATION}
className="w-full"
/>
@ -271,5 +471,7 @@ const SubscriptionBadge = ({ user }: { user: User }) => {
</Button>
</HoverCardContent>
</HoverCard>
</div>
)
}
// #endregion

View File

@ -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 }) {
</div>
<div className="flex items-center space-x-4">
<ThemeSwitcher />
{Boolean(userData?.id) ? <UserButton userData={userData} /> : null}
{Boolean(userData?.id) ? (
<UserButton userData={userData} />
) : (
<Link href="/sign-in">
<Button>Login</Button>
</Link>
)}
</div>
</nav>
)

View File

@ -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" }
}
}

View File

@ -23,7 +23,9 @@ export type Sandbox = {
viewCount: number
usersToSandboxes: UsersToSandboxes[]
}
export type SandboxWithLiked = Sandbox & {
liked: boolean
}
export type UsersToSandboxes = {
userId: string
sandboxId: string