diff --git a/.vscode/settings.json b/.vscode/settings.json index 40ceabb..3fed363 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,9 @@ "source.fixAll": "explicit", "source.organizeImports": "explicit" }, + "tailwindCSS.experimental.classRegex": [ + ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], + ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + ], "editor.defaultFormatter": "esbenp.prettier-vscode" } diff --git a/backend/database/src/index.ts b/backend/database/src/index.ts index dd807e2..5fd6f50 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( @@ -238,15 +251,59 @@ export default { 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() @@ -267,8 +324,8 @@ export default { }) const body = await request.json() - const { id, name, email, username, avatarUrl, createdAt, generations, tier, tierExpiresAt, lastResetDate } = userSchema.parse(body) + const { id, name, email, username, avatarUrl, createdAt, generations, tier, tierExpiresAt, lastResetDate } = userSchema.parse(body) const res = await db .insert(user) .values({ @@ -293,6 +350,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 } @@ -304,7 +412,7 @@ export default { if (!username) return invalidRequest const exists = await db.query.user.findFirst({ - where: (user, { eq }) => eq(user.username, username) + where: (user, { eq }) => eq(user.username, username), }) return json({ exists: !!exists }) diff --git a/backend/database/src/schema.ts b/backend/database/src/schema.ts index 6603c79..655c897 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,8 +12,9 @@ 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), tier: text("tier", { enum: ["FREE", "PRO", "ENTERPRISE"] }).default("FREE"), tierExpiresAt: integer("tierExpiresAt"), @@ -22,11 +23,6 @@ export const user = sqliteTable("user", { 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()) @@ -35,8 +31,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), @@ -46,13 +43,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") @@ -64,6 +71,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 }) => ({ @@ -77,3 +111,5 @@ export const usersToSandboxesRelations = relations( }), }) ) + +// #endregion diff --git a/frontend/app/[username]/page.tsx b/frontend/app/[username]/page.tsx new file mode 100644 index 0000000..87e8ef4 --- /dev/null +++ b/frontend/app/[username]/page.tsx @@ -0,0 +1,65 @@ +import ProfilePage from "@/components/profile" +import ProfileNavbar from "@/components/profile/navbar" +import { SandboxWithLiked, User } from "@/lib/types" +import { currentUser } from "@clerk/nextjs" +import { notFound } from "next/navigation" + +export default async function Page({ + params: { username: rawUsername }, +}: { + params: { username: string } +}) { + const username = decodeURIComponent(rawUsername).replace("@", "") + const loggedInClerkUser = await currentUser() + + const [profileOwnerResponse, loggedInUserResponse] = await Promise.all([ + fetch( + `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?username=${username}¤tUserId=${loggedInClerkUser?.id}`, + { + headers: { + Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, + }, + } + ), + fetch( + `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${loggedInClerkUser?.id}`, + { + headers: { + Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, + }, + } + ), + ]) + + const profileOwner = (await profileOwnerResponse.json()) as User + const loggedInUser = (await loggedInUserResponse.json()) as User + + if (!Boolean(profileOwner?.id)) { + notFound() + } + const publicSandboxes: SandboxWithLiked[] = [] + const privateSandboxes: SandboxWithLiked[] = [] + + profileOwner?.sandbox?.forEach((sandbox) => { + if (sandbox.visibility === "public") { + publicSandboxes.push(sandbox as SandboxWithLiked) + } else if (sandbox.visibility === "private") { + privateSandboxes.push(sandbox as SandboxWithLiked) + } + }) + + const isUserLoggedIn = Boolean(loggedInUser?.id) + return ( +
+ + +
+ ) +} diff --git a/frontend/components/dashboard/navbar/index.tsx b/frontend/components/dashboard/navbar/index.tsx index 5409236..310a14d 100644 --- a/frontend/components/dashboard/navbar/index.tsx +++ b/frontend/components/dashboard/navbar/index.tsx @@ -8,7 +8,7 @@ import DashboardNavbarSearch from "./search" export default function DashboardNavbar({ userData }: { userData: User }) { return ( -
+
void - onDelete: (sandbox: Sandbox) => void + visibility: Sandbox["visibility"] + onVisibilityChange: () => void + onDelete: () => void }) { return ( @@ -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" > @@ -34,11 +34,11 @@ export default function ProjectCardDropdown({ { e.stopPropagation() - onVisibilityChange(sandbox) + onVisibilityChange() }} className="cursor-pointer" > - {sandbox.visibility === "public" ? ( + {visibility === "public" ? ( <> Make Private @@ -53,7 +53,7 @@ export default function ProjectCardDropdown({ { e.stopPropagation() - onDelete(sandbox) + onDelete() }} className="!text-destructive cursor-pointer" > diff --git a/frontend/components/dashboard/projectCard/index.tsx b/frontend/components/dashboard/projectCard/index.tsx index a463e84..4c1ec42 100644 --- a/frontend/components/dashboard/projectCard/index.tsx +++ b/frontend/components/dashboard/projectCard/index.tsx @@ -1,59 +1,235 @@ "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, Globe, Lock } from "lucide-react" +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 { useEffect, useState } from "react" +import { + memo, + MouseEventHandler, + useEffect, + useMemo, + useOptimistic, + useState, + useTransition, +} from "react" import ProjectCardDropdown from "./dropdown" +import { CanvasRevealEffect } from "./revealEffect" -export default function ProjectCard({ - children, - sandbox, - onVisibilityChange, - onDelete, - deletingId, -}: { - children?: React.ReactNode - sandbox: Sandbox - onVisibilityChange: (sandbox: Sandbox) => void - onDelete: (sandbox: Sandbox) => void +type BaseProjectCardProps = { + id: string + name: string + type: string + visibility: "public" | "private" + createdAt: Date + likeCount: number + liked?: boolean + viewCount: number +} + +type AuthenticatedProjectCardProps = BaseProjectCardProps & { + isAuthenticated: true + onVisibilityChange: ( + sandbox: Pick + ) => void + onDelete: (sandbox: Pick) => void deletingId: string -}) { +} + +type UnauthenticatedProjectCardProps = BaseProjectCardProps & { + isAuthenticated: false +} + +type ProjectCardProps = + | AuthenticatedProjectCardProps + | UnauthenticatedProjectCardProps + +const StatItem = memo(({ icon: Icon, value }: { icon: any; value: number }) => ( +
+ + {value} +
+)) + +StatItem.displayName = "StatItem" + +const formatDate = (date: Date): string => { + const now = new Date() + const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / 60000) + + if (diffInMinutes < 1) return "Now" + if (diffInMinutes < 60) return `${diffInMinutes}m ago` + if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h ago` + return `${Math.floor(diffInMinutes / 1440)}d ago` +} + +const ProjectMetadata = memo( + ({ + id, + visibility, + createdAt, + likeCount, + liked, + viewCount, + }: Pick< + BaseProjectCardProps, + "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]) + + return ( +
+
+
+ + + {visibility === "private" ? "Private" : "Public"} + +
+
+
+
+ {date} +
+ + +
+
+ ) + } +) + +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, + type, + visibility, + createdAt, + likeCount, + viewCount, + ...props +}: ProjectCardProps) { const [hovered, setHovered] = useState(false) - const [date, setDate] = useState() const router = useRouter() - useEffect(() => { - const createdAt = new Date(sandbox.createdAt) - const now = new Date() - const diffInMinutes = Math.floor( - (now.getTime() - createdAt.getTime()) / 60000 - ) + const projectIcon = useMemo( + () => + projectTemplates.find((p) => p.id === type)?.icon ?? + "/project-icons/node.svg", + [type] + ) - if (diffInMinutes < 1) { - setDate("Now") - } else if (diffInMinutes < 60) { - setDate(`${diffInMinutes}m ago`) - } else if (diffInMinutes < 1440) { - setDate(`${Math.floor(diffInMinutes / 60)}h ago`) - } else { - setDate(`${Math.floor(diffInMinutes / 1440)}d ago`) + const handleVisibilityChange = () => { + if (props.isAuthenticated) { + props.onVisibilityChange({ + id, + name, + visibility, + }) } - }, [sandbox]) - const projectIcon = - projectTemplates.find((p) => p.id === sandbox.type)?.icon ?? - "/project-icons/node.svg" + } + + const handleDelete = () => { + if (props.isAuthenticated) { + props.onDelete({ + id, + name, + }) + } + } + return ( router.push(`/code/${sandbox.id}`)} + onClick={() => router.push(`/code/${id}`)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} - 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`} + 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 pointer-events-none cursor-events-none" + : "cursor-pointer" + } + `} > {hovered && ( @@ -62,38 +238,64 @@ export default function ProjectCard({ animate={{ opacity: 1 }} className="h-full w-full absolute inset-0" > - {children} + +
)}
- -
- {sandbox.name} -
- + + {name} + + {props.isAuthenticated && ( + + )}
-
-
- {sandbox.visibility === "private" ? ( - <> - Private - - ) : ( - <> - Public - - )} -
-
- {date} -
-
+ + ) } + +ProjectCardComponent.displayName = "ProjectCard" + +const ProjectCard = memo(ProjectCardComponent) + +export default ProjectCard + +const colors: { [key: string]: number[][] } = { + react: [ + [71, 207, 237], + [30, 126, 148], + ], + node: [ + [86, 184, 72], + [59, 112, 52], + ], +} diff --git a/frontend/components/dashboard/projects.tsx b/frontend/components/dashboard/projects.tsx index 7c9705d..14618fa 100644 --- a/frontend/components/dashboard/projects.tsx +++ b/frontend/components/dashboard/projects.tsx @@ -2,11 +2,9 @@ import { deleteSandbox, updateSandbox } from "@/lib/actions" import { Sandbox } from "@/lib/types" -import Link from "next/link" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { toast } from "sonner" import ProjectCard from "./projectCard" -import { CanvasRevealEffect } from "./projectCard/revealEffect" const colors: { [key: string]: number[][] } = { react: [ @@ -28,11 +26,27 @@ export default function DashboardProjects({ }) { const [deletingId, setDeletingId] = useState("") - const onDelete = async (sandbox: Sandbox) => { - setDeletingId(sandbox.id) - toast(`Project ${sandbox.name} deleted.`) - await deleteSandbox(sandbox.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) + }, + [] + ) useEffect(() => { if (deletingId) { @@ -40,15 +54,6 @@ export default function DashboardProjects({ } }, [sandboxes]) - const onVisibilityChange = async (sandbox: Sandbox) => { - const newVisibility = sandbox.visibility === "public" ? "private" : "public" - toast(`Project ${sandbox.name} is now ${newVisibility}.`) - await updateSandbox({ - id: sandbox.id, - visibility: newVisibility, - }) - } - return (
@@ -64,30 +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 new file mode 100644 index 0000000..4246da5 --- /dev/null +++ b/frontend/components/profile/index.tsx @@ -0,0 +1,477 @@ +"use client" + +import NewProjectModal from "@/components/dashboard/newProject" +import ProjectCard from "@/components/dashboard/projectCard/" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardTitle, +} from "@/components/ui/card" +import { + HoverCard, + 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, updateUser } from "@/lib/actions" +import { MAX_FREE_GENERATION } from "@/lib/constant" +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, + profileOwner, + loggedInUser, +}: { + 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 ( + <> +
+
+ +
+
+ +
+
+ + ) +} +// #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 ( + + {isOwnProfile && ( + + )} + + + + {!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 +} + +const StatsItem = ({ icon: Icon, label }: StatsItemProps) => ( +
+ + {label} +
+) +// #endregion + +// #region Sub Badge +const SubscriptionBadge = ({ generations }: { generations: number }) => { + return ( +
+ + Free + + + + + + +
+
+ AI Generations + {`${generations} / ${MAX_FREE_GENERATION}`} +
+ +
+ +
+
+
+ ) +} +// #endregion diff --git a/frontend/components/profile/navbar.tsx b/frontend/components/profile/navbar.tsx new file mode 100644 index 0000000..f7e8648 --- /dev/null +++ b/frontend/components/profile/navbar.tsx @@ -0,0 +1,33 @@ +import Logo from "@/assets/logo.svg" +import { ThemeSwitcher } from "@/components/ui/theme-switcher" +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 ( + + ) +} diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx index 32e09ff..ef43feb 100644 --- a/frontend/components/ui/avatar.tsx +++ b/frontend/components/ui/avatar.tsx @@ -1,5 +1,4 @@ import { cn } from "@/lib/utils" -import Image from "next/image" export default function Avatar({ name, @@ -22,12 +21,12 @@ export default function Avatar({ return (
{avatarUrl ? ( - {name, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx index e3b682e..3e9fc73 100644 --- a/frontend/components/ui/button.tsx +++ b/frontend/components/ui/button.tsx @@ -5,7 +5,7 @@ import * as React from "react" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition active:scale-[0.99] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { diff --git a/frontend/components/ui/hover-card.tsx b/frontend/components/ui/hover-card.tsx new file mode 100644 index 0000000..e54d91c --- /dev/null +++ b/frontend/components/ui/hover-card.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "@/lib/utils" + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/frontend/components/ui/progress.tsx b/frontend/components/ui/progress.tsx index efbac30..f0434de 100644 --- a/frontend/components/ui/progress.tsx +++ b/frontend/components/ui/progress.tsx @@ -18,7 +18,7 @@ const Progress = React.forwardRef< {...props} > diff --git a/frontend/components/ui/tabs.tsx b/frontend/components/ui/tabs.tsx new file mode 100644 index 0000000..0f4caeb --- /dev/null +++ b/frontend/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/frontend/components/ui/theme-switcher.tsx b/frontend/components/ui/theme-switcher.tsx index 9e0bc40..f746760 100644 --- a/frontend/components/ui/theme-switcher.tsx +++ b/frontend/components/ui/theme-switcher.tsx @@ -17,7 +17,7 @@ export function ThemeSwitcher() { return ( -