feat: complete profile page with profile edit, project likes and UI updates
This commit is contained in:
parent
105eab9bad
commit
06a5d46e1f
@ -5,7 +5,13 @@ import { z } from "zod"
|
|||||||
|
|
||||||
import { and, eq, sql } from "drizzle-orm"
|
import { and, eq, sql } from "drizzle-orm"
|
||||||
import * as schema from "./schema"
|
import * as schema from "./schema"
|
||||||
import { sandbox, user, usersToSandboxes } from "./schema"
|
import {
|
||||||
|
Sandbox,
|
||||||
|
sandbox,
|
||||||
|
sandboxLikes,
|
||||||
|
user,
|
||||||
|
usersToSandboxes,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
@ -18,6 +24,13 @@ export interface Env {
|
|||||||
|
|
||||||
// npm run generate
|
// npm run generate
|
||||||
// npx wrangler d1 execute d1-sandbox --local --file=./drizzle/<FILE>
|
// 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 {
|
export default {
|
||||||
async fetch(
|
async fetch(
|
||||||
@ -258,33 +271,147 @@ export default {
|
|||||||
.get()
|
.get()
|
||||||
|
|
||||||
return success
|
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") {
|
} else if (path === "/api/user") {
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
const params = url.searchParams
|
const params = url.searchParams
|
||||||
|
|
||||||
if (params.has("id")) {
|
if (params.has("id")) {
|
||||||
const id = params.get("id") as string
|
const id = params.get("id") as string
|
||||||
|
|
||||||
const res = await db.query.user.findFirst({
|
const res = await db.query.user.findFirst({
|
||||||
where: (user, { eq }) => eq(user.id, id),
|
where: (user, { eq }) => eq(user.id, id),
|
||||||
with: {
|
with: {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
|
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
|
||||||
|
with: {
|
||||||
|
likes: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
usersToSandboxes: 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 ?? {})
|
return json(res ?? {})
|
||||||
} else if (params.has("username")) {
|
} else if (params.has("username")) {
|
||||||
const username = params.get("username") as string
|
const username = params.get("username") as string
|
||||||
|
const userId = params.get("currentUserId")
|
||||||
const res = await db.query.user.findFirst({
|
const res = await db.query.user.findFirst({
|
||||||
where: (user, { eq }) => eq(user.username, username),
|
where: (user, { eq }) => eq(user.username, username),
|
||||||
with: {
|
with: {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
|
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
|
||||||
|
with: {
|
||||||
|
likes: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
usersToSandboxes: 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 ?? {})
|
return json(res ?? {})
|
||||||
} else {
|
} else {
|
||||||
const res = await db.select().from(user).all()
|
const res = await db.select().from(user).all()
|
||||||
@ -326,6 +453,57 @@ export default {
|
|||||||
await db.delete(user).where(eq(user.id, id))
|
await db.delete(user).where(eq(user.id, id))
|
||||||
return success
|
return success
|
||||||
} else return invalidRequest
|
} 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 {
|
} else {
|
||||||
return methodNotAllowed
|
return methodNotAllowed
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { relations } from "drizzle-orm"
|
import { relations, sql } from "drizzle-orm"
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||||
import { sql } from "drizzle-orm"
|
|
||||||
|
|
||||||
|
// #region Tables
|
||||||
export const user = sqliteTable("user", {
|
export const user = sqliteTable("user", {
|
||||||
id: text("id")
|
id: text("id")
|
||||||
.$defaultFn(() => createId())
|
.$defaultFn(() => createId())
|
||||||
@ -12,18 +12,14 @@ export const user = sqliteTable("user", {
|
|||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
username: text("username").notNull().unique(),
|
username: text("username").notNull().unique(),
|
||||||
avatarUrl: text("avatarUrl"),
|
avatarUrl: text("avatarUrl"),
|
||||||
createdAt: integer("createdAt", { mode: "timestamp_ms" })
|
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
sql`CURRENT_TIMESTAMP`
|
||||||
|
),
|
||||||
generations: integer("generations").default(0),
|
generations: integer("generations").default(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type User = typeof user.$inferSelect
|
export type User = typeof user.$inferSelect
|
||||||
|
|
||||||
export const userRelations = relations(user, ({ many }) => ({
|
|
||||||
sandbox: many(sandbox),
|
|
||||||
usersToSandboxes: many(usersToSandboxes),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const sandbox = sqliteTable("sandbox", {
|
export const sandbox = sqliteTable("sandbox", {
|
||||||
id: text("id")
|
id: text("id")
|
||||||
.$defaultFn(() => createId())
|
.$defaultFn(() => createId())
|
||||||
@ -32,8 +28,9 @@ export const sandbox = sqliteTable("sandbox", {
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
type: text("type").notNull(),
|
type: text("type").notNull(),
|
||||||
visibility: text("visibility", { enum: ["public", "private"] }),
|
visibility: text("visibility", { enum: ["public", "private"] }),
|
||||||
createdAt: integer("createdAt", { mode: "timestamp_ms" })
|
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
sql`CURRENT_TIMESTAMP`
|
||||||
|
),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id),
|
.references(() => user.id),
|
||||||
@ -43,13 +40,23 @@ export const sandbox = sqliteTable("sandbox", {
|
|||||||
|
|
||||||
export type Sandbox = typeof sandbox.$inferSelect
|
export type Sandbox = typeof sandbox.$inferSelect
|
||||||
|
|
||||||
export const sandboxRelations = relations(sandbox, ({ one, many }) => ({
|
export const sandboxLikes = sqliteTable(
|
||||||
author: one(user, {
|
"sandbox_likes",
|
||||||
fields: [sandbox.userId],
|
{
|
||||||
references: [user.id],
|
userId: text("user_id")
|
||||||
}),
|
.notNull()
|
||||||
usersToSandboxes: many(usersToSandboxes),
|
.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", {
|
export const usersToSandboxes = sqliteTable("users_to_sandboxes", {
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
@ -61,6 +68,33 @@ export const usersToSandboxes = sqliteTable("users_to_sandboxes", {
|
|||||||
sharedOn: integer("sharedOn", { mode: "timestamp_ms" }),
|
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(
|
export const usersToSandboxesRelations = relations(
|
||||||
usersToSandboxes,
|
usersToSandboxes,
|
||||||
({ one }) => ({
|
({ one }) => ({
|
||||||
@ -74,3 +108,5 @@ export const usersToSandboxesRelations = relations(
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// #endregion
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import ProfilePage from "@/components/profile"
|
import ProfilePage from "@/components/profile"
|
||||||
import ProfileNavbar from "@/components/profile/navbar"
|
import ProfileNavbar from "@/components/profile/navbar"
|
||||||
import { Sandbox, User } from "@/lib/types"
|
import { SandboxWithLiked, User } from "@/lib/types"
|
||||||
import { currentUser } from "@clerk/nextjs"
|
import { currentUser } from "@clerk/nextjs"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
export default async function Page({
|
export default async function Page({
|
||||||
params: { username: rawUsername },
|
params: { username: rawUsername },
|
||||||
@ -9,11 +10,11 @@ export default async function Page({
|
|||||||
params: { username: string }
|
params: { username: string }
|
||||||
}) {
|
}) {
|
||||||
const username = decodeURIComponent(rawUsername).replace("@", "")
|
const username = decodeURIComponent(rawUsername).replace("@", "")
|
||||||
const currentLoggedInUser = await currentUser()
|
const loggedInClerkUser = await currentUser()
|
||||||
console.log(username)
|
|
||||||
const [profileRespnse, dbUserResponse] = await Promise.all([
|
const [profileOwnerResponse, loggedInUserResponse] = await Promise.all([
|
||||||
fetch(
|
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: {
|
headers: {
|
||||||
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
||||||
@ -21,7 +22,7 @@ export default async function Page({
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
fetch(
|
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: {
|
headers: {
|
||||||
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
||||||
@ -30,30 +31,35 @@ export default async function Page({
|
|||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
const userProfile = (await profileRespnse.json()) as User
|
const profileOwner = (await profileOwnerResponse.json()) as User
|
||||||
const dbUserData = (await dbUserResponse.json()) as User
|
const loggedInUser = (await loggedInUserResponse.json()) as User
|
||||||
const publicSandboxes: Sandbox[] = []
|
|
||||||
const privateSandboxes: Sandbox[] = []
|
|
||||||
|
|
||||||
userProfile?.sandbox?.forEach((sandbox) => {
|
if (!Boolean(profileOwner?.id)) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
const publicSandboxes: SandboxWithLiked[] = []
|
||||||
|
const privateSandboxes: SandboxWithLiked[] = []
|
||||||
|
|
||||||
|
profileOwner?.sandbox?.forEach((sandbox) => {
|
||||||
if (sandbox.visibility === "public") {
|
if (sandbox.visibility === "public") {
|
||||||
publicSandboxes.push(sandbox)
|
publicSandboxes.push(sandbox as SandboxWithLiked)
|
||||||
} else if (sandbox.visibility === "private") {
|
} else if (sandbox.visibility === "private") {
|
||||||
privateSandboxes.push(sandbox)
|
privateSandboxes.push(sandbox as SandboxWithLiked)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const hasCurrentUser = Boolean(dbUserData?.id)
|
|
||||||
|
const isUserLoggedIn = Boolean(loggedInUser?.id)
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<section>
|
||||||
<ProfileNavbar userData={dbUserData} />
|
<ProfileNavbar userData={loggedInUser} />
|
||||||
<ProfilePage
|
<ProfilePage
|
||||||
publicSandboxes={publicSandboxes}
|
publicSandboxes={publicSandboxes}
|
||||||
privateSandboxes={
|
privateSandboxes={
|
||||||
userProfile?.id === dbUserData.id ? privateSandboxes : []
|
profileOwner?.id === loggedInUser.id ? privateSandboxes : []
|
||||||
}
|
}
|
||||||
user={userProfile}
|
profileOwner={profileOwner}
|
||||||
currentUser={hasCurrentUser ? dbUserData : null}
|
loggedInUser={isUserLoggedIn ? loggedInUser : null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@ export default function ProjectCardDropdown({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
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" />
|
<Ellipsis className="w-4 h-4" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
@ -1,13 +1,26 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { toggleLike } from "@/lib/actions"
|
||||||
import { projectTemplates } from "@/lib/data"
|
import { projectTemplates } from "@/lib/data"
|
||||||
import { Sandbox } from "@/lib/types"
|
import { Sandbox } from "@/lib/types"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useUser } from "@clerk/nextjs"
|
||||||
import { AnimatePresence, motion } from "framer-motion"
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
import { Clock, Eye, Globe, Heart, Lock } from "lucide-react"
|
import { Clock, Eye, Globe, Heart, Lock } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
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 ProjectCardDropdown from "./dropdown"
|
||||||
import { CanvasRevealEffect } from "./revealEffect"
|
import { CanvasRevealEffect } from "./revealEffect"
|
||||||
|
|
||||||
@ -18,6 +31,7 @@ type BaseProjectCardProps = {
|
|||||||
visibility: "public" | "private"
|
visibility: "public" | "private"
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
likeCount: number
|
likeCount: number
|
||||||
|
liked?: boolean
|
||||||
viewCount: number
|
viewCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,16 +73,19 @@ const formatDate = (date: Date): string => {
|
|||||||
|
|
||||||
const ProjectMetadata = memo(
|
const ProjectMetadata = memo(
|
||||||
({
|
({
|
||||||
|
id,
|
||||||
visibility,
|
visibility,
|
||||||
createdAt,
|
createdAt,
|
||||||
likeCount,
|
likeCount,
|
||||||
|
liked,
|
||||||
viewCount,
|
viewCount,
|
||||||
}: Pick<
|
}: Pick<
|
||||||
BaseProjectCardProps,
|
BaseProjectCardProps,
|
||||||
"visibility" | "createdAt" | "likeCount" | "viewCount"
|
"visibility" | "createdAt" | "likeCount" | "liked" | "viewCount" | "id"
|
||||||
>) => {
|
>) => {
|
||||||
|
const { user } = useUser()
|
||||||
const [date, setDate] = useState<string>()
|
const [date, setDate] = useState<string>()
|
||||||
|
const Icon = visibility === "private" ? Lock : Globe
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDate(formatDate(new Date(createdAt)))
|
setDate(formatDate(new Date(createdAt)))
|
||||||
}, [createdAt])
|
}, [createdAt])
|
||||||
@ -76,23 +93,23 @@ const ProjectMetadata = memo(
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col text-muted-foreground space-y-2 text-sm z-10">
|
<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 justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-2">
|
||||||
{visibility === "private" ? (
|
<Icon className="size-4" />
|
||||||
<>
|
<span className="text-xs">
|
||||||
<Lock className="size-4 mr-2" /> Private
|
{visibility === "private" ? "Private" : "Public"}
|
||||||
</>
|
</span>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Globe className="size-4 mr-2" /> Public
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="size-4 mr-2" /> {date}
|
<Clock className="size-4" /> <span className="text-xs">{date}</span>
|
||||||
</div>
|
</div>
|
||||||
<StatItem icon={Heart} value={likeCount} />
|
<LikeButton
|
||||||
|
sandboxId={id}
|
||||||
|
initialIsLiked={!!liked}
|
||||||
|
initialLikeCount={likeCount}
|
||||||
|
userId={user?.id ?? null}
|
||||||
|
/>
|
||||||
<StatItem icon={Eye} value={viewCount} />
|
<StatItem icon={Eye} value={viewCount} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -102,6 +119,63 @@ const ProjectMetadata = memo(
|
|||||||
|
|
||||||
ProjectMetadata.displayName = "ProjectMetadata"
|
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({
|
function ProjectCardComponent({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@ -150,7 +224,11 @@ function ProjectCardComponent({
|
|||||||
className={`
|
className={`
|
||||||
group/canvas-card p-4 h-48 flex flex-col justify-between items-start
|
group/canvas-card p-4 h-48 flex flex-col justify-between items-start
|
||||||
hover:border-muted-foreground/50 relative overflow-hidden transition-all
|
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>
|
<AnimatePresence>
|
||||||
@ -178,9 +256,12 @@ function ProjectCardComponent({
|
|||||||
width={20}
|
width={20}
|
||||||
height={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}
|
{name}
|
||||||
</div>
|
</Link>
|
||||||
{props.isAuthenticated && (
|
{props.isAuthenticated && (
|
||||||
<ProjectCardDropdown
|
<ProjectCardDropdown
|
||||||
onVisibilityChange={handleVisibilityChange}
|
onVisibilityChange={handleVisibilityChange}
|
||||||
@ -195,6 +276,8 @@ function ProjectCardComponent({
|
|||||||
createdAt={createdAt}
|
createdAt={createdAt}
|
||||||
likeCount={likeCount}
|
likeCount={likeCount}
|
||||||
viewCount={viewCount}
|
viewCount={viewCount}
|
||||||
|
id={id}
|
||||||
|
liked={props.liked}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { deleteSandbox, updateSandbox } from "@/lib/actions"
|
import { deleteSandbox, updateSandbox } from "@/lib/actions"
|
||||||
import { Sandbox } from "@/lib/types"
|
import { Sandbox } from "@/lib/types"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import ProjectCard from "./projectCard"
|
import ProjectCard from "./projectCard"
|
||||||
@ -71,24 +69,14 @@ export default function DashboardProjects({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
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
|
<ProjectCard
|
||||||
|
key={sandbox.id}
|
||||||
onVisibilityChange={onVisibilityChange}
|
onVisibilityChange={onVisibilityChange}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
deletingId={deletingId}
|
deletingId={deletingId}
|
||||||
isAuthenticated
|
isAuthenticated
|
||||||
{...sandbox}
|
{...sandbox}
|
||||||
/>
|
/>
|
||||||
</Link>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import NewProjectModal from "@/components/dashboard/newProject"
|
||||||
import ProjectCard from "@/components/dashboard/projectCard/"
|
import ProjectCard from "@/components/dashboard/projectCard/"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@ -13,37 +14,259 @@ import {
|
|||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
HoverCardTrigger,
|
HoverCardTrigger,
|
||||||
} from "@/components/ui/hover-card"
|
} from "@/components/ui/hover-card"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
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 { MAX_FREE_GENERATION } from "@/lib/constant"
|
||||||
import { Sandbox, User } from "@/lib/types"
|
import { SandboxWithLiked, User } from "@/lib/types"
|
||||||
import { cn } from "@/lib/utils"
|
import { useUser } from "@clerk/nextjs"
|
||||||
import { Heart, LucideIcon, Package2, PlusCircle, Sparkles } from "lucide-react"
|
import {
|
||||||
import Link from "next/link"
|
Edit,
|
||||||
import { useMemo, useState } from "react"
|
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 { toast } from "sonner"
|
||||||
import Avatar from "../ui/avatar"
|
import Avatar from "../ui/avatar"
|
||||||
import { Badge } from "../ui/badge"
|
import { Badge } from "../ui/badge"
|
||||||
|
import { Input } from "../ui/input"
|
||||||
import { Progress } from "../ui/progress"
|
import { Progress } from "../ui/progress"
|
||||||
|
|
||||||
|
// #region Profile Page
|
||||||
export default function ProfilePage({
|
export default function ProfilePage({
|
||||||
publicSandboxes,
|
publicSandboxes,
|
||||||
privateSandboxes,
|
privateSandboxes,
|
||||||
user,
|
profileOwner,
|
||||||
currentUser,
|
loggedInUser,
|
||||||
}: {
|
}: {
|
||||||
publicSandboxes: Sandbox[]
|
publicSandboxes: SandboxWithLiked[]
|
||||||
privateSandboxes: Sandbox[]
|
privateSandboxes: SandboxWithLiked[]
|
||||||
user: User
|
profileOwner: User
|
||||||
currentUser: User | null
|
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 [deletingId, setDeletingId] = useState<string>("")
|
||||||
const isLoggedIn = Boolean(currentUser)
|
|
||||||
const hasPublicSandboxes = publicSandboxes.length > 0
|
const hasPublicSandboxes = publicSandboxes.length > 0
|
||||||
const hasPrivateSandboxes = privateSandboxes.length > 0
|
const hasPrivateSandboxes = privateSandboxes.length > 0
|
||||||
|
|
||||||
const onVisibilityChange = useMemo(
|
const onVisibilityChange = useMemo(
|
||||||
() => async (sandbox: Pick<Sandbox, "id" | "name" | "visibility">) => {
|
() =>
|
||||||
|
async (sandbox: Pick<SandboxWithLiked, "id" | "name" | "visibility">) => {
|
||||||
const newVisibility =
|
const newVisibility =
|
||||||
sandbox.visibility === "public" ? "private" : "public"
|
sandbox.visibility === "public" ? "private" : "public"
|
||||||
toast(`Project ${sandbox.name} is now ${newVisibility}.`)
|
toast(`Project ${sandbox.name} is now ${newVisibility}.`)
|
||||||
@ -56,7 +279,7 @@ export default function ProfilePage({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onDelete = useMemo(
|
const onDelete = useMemo(
|
||||||
() => async (sandbox: Pick<Sandbox, "id" | "name">) => {
|
() => async (sandbox: Pick<SandboxWithLiked, "id" | "name">) => {
|
||||||
setDeletingId(sandbox.id)
|
setDeletingId(sandbox.id)
|
||||||
toast(`Project ${sandbox.name} deleted.`)
|
toast(`Project ${sandbox.name} deleted.`)
|
||||||
await deleteSandbox(sandbox.id)
|
await deleteSandbox(sandbox.id)
|
||||||
@ -64,81 +287,17 @@ export default function ProfilePage({
|
|||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
const stats = useMemo(() => {
|
if (!isOwnProfile) {
|
||||||
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]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="">
|
||||||
<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">
|
|
||||||
{hasPublicSandboxes ? (
|
{hasPublicSandboxes ? (
|
||||||
|
<>
|
||||||
|
<h2 className="font-semibold text-xl mb-4">Sandboxes</h2>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{publicSandboxes.map((sandbox) => {
|
{publicSandboxes.map((sandbox) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Fragment key={sandbox.id}>
|
||||||
key={sandbox.id}
|
{isOwnProfile ? (
|
||||||
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 ? (
|
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
onVisibilityChange={onVisibilityChange}
|
onVisibilityChange={onVisibilityChange}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@ -149,37 +308,30 @@ export default function ProfilePage({
|
|||||||
) : (
|
) : (
|
||||||
<ProjectCard isAuthenticated={false} {...sandbox} />
|
<ProjectCard isAuthenticated={false} {...sandbox} />
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState type="private" isOwnProfile={isOwnProfile} />
|
||||||
title="No public sandboxes yet"
|
)}
|
||||||
description={
|
</div>
|
||||||
isLoggedIn
|
)
|
||||||
? "Create your first public sandbox to share your work with the world!"
|
|
||||||
: "Login to create public sandboxes"
|
|
||||||
}
|
}
|
||||||
isLoggedIn={isLoggedIn}
|
return (
|
||||||
/>
|
<Tabs defaultValue="public">
|
||||||
)}
|
<TabsList className="mb-4">
|
||||||
</TabsContent>
|
<TabsTrigger value="public">Public</TabsTrigger>
|
||||||
{isLoggedIn && (
|
<TabsTrigger value="private">Private</TabsTrigger>
|
||||||
<TabsContent value="private">
|
</TabsList>
|
||||||
{hasPrivateSandboxes ? (
|
<TabsContent value="public">
|
||||||
|
{hasPublicSandboxes ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{privateSandboxes.map((sandbox) => (
|
{publicSandboxes.map((sandbox) => {
|
||||||
<Link
|
return (
|
||||||
key={sandbox.id}
|
<Fragment key={sandbox.id}>
|
||||||
href={`/code/${sandbox.id}`}
|
{isOwnProfile ? (
|
||||||
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
|
<ProjectCard
|
||||||
onVisibilityChange={onVisibilityChange}
|
onVisibilityChange={onVisibilityChange}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@ -187,53 +339,95 @@ export default function ProfilePage({
|
|||||||
isAuthenticated
|
isAuthenticated
|
||||||
{...sandbox}
|
{...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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState type="private" isOwnProfile={isOwnProfile} />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Empty State
|
||||||
function EmptyState({
|
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,
|
title,
|
||||||
description,
|
description,
|
||||||
isLoggedIn,
|
}
|
||||||
}: {
|
}, [type, isOwnProfile])
|
||||||
title: string
|
const openModal = useCallback(() => setNewProjectModalOpen(true), [])
|
||||||
description: string
|
|
||||||
isLoggedIn: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card className="flex flex-col items-center justify-center p-6 text-center h-[300px]">
|
<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" />
|
<PlusCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<CardTitle className="text-xl mb-2">{title}</CardTitle>
|
<CardTitle className="text-xl mb-2">{text.title}</CardTitle>
|
||||||
<CardDescription className="mb-4">{description}</CardDescription>
|
<CardDescription className="mb-4">{text.description}</CardDescription>
|
||||||
{isLoggedIn && (
|
{isOwnProfile && (
|
||||||
<Button>
|
<Button onClick={openModal}>
|
||||||
<PlusCircle className="h-4 w-4 mr-2" />
|
<PlusCircle className="h-4 w-4 mr-2" />
|
||||||
Create Sandbox
|
Create Sandbox
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
<NewProjectModal
|
||||||
|
open={newProjectModalOpen}
|
||||||
|
setOpen={setNewProjectModalOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region StatsItem
|
||||||
interface StatsItemProps {
|
interface StatsItemProps {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
label: string
|
label: string
|
||||||
@ -245,23 +439,29 @@ const StatsItem = ({ icon: Icon, label }: StatsItemProps) => (
|
|||||||
<span className="text-sm text-muted-foreground">{label}</span>
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
// #endregion
|
||||||
|
|
||||||
const SubscriptionBadge = ({ user }: { user: User }) => {
|
// #region Sub Badge
|
||||||
|
const SubscriptionBadge = ({ generations }: { generations: number }) => {
|
||||||
return (
|
return (
|
||||||
<HoverCard>
|
<div className="flex gap-2 items-center">
|
||||||
<HoverCardTrigger>
|
<Badge variant="secondary" className="text-sm cursor-pointer">
|
||||||
<Badge variant="secondary" className="text-xs cursor-pointer">
|
|
||||||
Free
|
Free
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger>
|
||||||
|
<Button variant="ghost" size="smIcon">
|
||||||
|
<Info size={20} />
|
||||||
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent>
|
<HoverCardContent>
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="font-medium">AI Generations</span>
|
<span className="font-medium">AI Generations</span>
|
||||||
<span>{`${user.generations} / ${MAX_FREE_GENERATION}`}</span>
|
<span>{`${generations} / ${MAX_FREE_GENERATION}`}</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
value={user?.generations!}
|
value={generations}
|
||||||
max={MAX_FREE_GENERATION}
|
max={MAX_FREE_GENERATION}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -271,5 +471,7 @@ const SubscriptionBadge = ({ user }: { user: User }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
@ -4,6 +4,7 @@ import UserButton from "@/components/ui/userButton"
|
|||||||
import { User } from "@/lib/types"
|
import { User } from "@/lib/types"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
|
||||||
export default function ProfileNavbar({ userData }: { userData: User }) {
|
export default function ProfileNavbar({ userData }: { userData: User }) {
|
||||||
return (
|
return (
|
||||||
@ -19,7 +20,13 @@ export default function ProfileNavbar({ userData }: { userData: User }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
{Boolean(userData?.id) ? <UserButton userData={userData} /> : null}
|
{Boolean(userData?.id) ? (
|
||||||
|
<UserButton userData={userData} />
|
||||||
|
) : (
|
||||||
|
<Link href="/sign-in">
|
||||||
|
<Button>Login</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
export async function createSandbox(body: {
|
export async function createSandbox(body: {
|
||||||
type: string
|
type: string
|
||||||
@ -91,3 +92,95 @@ export async function unshareSandbox(sandboxId: string, userId: string) {
|
|||||||
|
|
||||||
revalidatePath(`/code/${sandboxId}`)
|
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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -23,7 +23,9 @@ export type Sandbox = {
|
|||||||
viewCount: number
|
viewCount: number
|
||||||
usersToSandboxes: UsersToSandboxes[]
|
usersToSandboxes: UsersToSandboxes[]
|
||||||
}
|
}
|
||||||
|
export type SandboxWithLiked = Sandbox & {
|
||||||
|
liked: boolean
|
||||||
|
}
|
||||||
export type UsersToSandboxes = {
|
export type UsersToSandboxes = {
|
||||||
userId: string
|
userId: string
|
||||||
sandboxId: string
|
sandboxId: string
|
||||||
|
Loading…
x
Reference in New Issue
Block a user