diff --git a/backend/database/src/index.ts b/backend/database/src/index.ts index 68e9ff9..db4f5e6 100644 --- a/backend/database/src/index.ts +++ b/backend/database/src/index.ts @@ -67,6 +67,7 @@ export default { const params = url.searchParams if (params.has("id")) { const id = params.get("id") as string + await db.delete(sandboxLikes).where(eq(sandboxLikes.sandboxId, id)) await db .delete(usersToSandboxes) .where(eq(usersToSandboxes.sandboxId, id)) @@ -239,6 +240,88 @@ export default { return success } else return methodNotAllowed + } 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 @@ -362,6 +445,16 @@ export default { const updateUserSchema = z.object({ id: z.string(), name: z.string().optional(), + bio: z.string().optional(), + personalWebsite: z.string().optional(), + links: z + .array( + z.object({ + url: z.string(), + platform: z.enum(schema.KNOWN_PLATFORMS), + }) + ) + .optional(), email: z.string().email().optional(), username: z.string().optional(), avatarUrl: z.string().optional(), diff --git a/backend/database/src/schema.ts b/backend/database/src/schema.ts index 0860923..70a3bff 100644 --- a/backend/database/src/schema.ts +++ b/backend/database/src/schema.ts @@ -2,6 +2,26 @@ import { createId } from "@paralleldrive/cuid2" import { relations, sql } from "drizzle-orm" import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core" +export const KNOWN_PLATFORMS = [ + "github", + "twitter", + "instagram", + "bluesky", + "linkedin", + "youtube", + "twitch", + "discord", + "mastodon", + "threads", + "gitlab", + "generic", +] as const + +export type KnownPlatform = (typeof KNOWN_PLATFORMS)[number] +export type UserLink = { + url: string + platform: KnownPlatform +} // #region Tables export const user = sqliteTable("user", { id: text("id") @@ -17,6 +37,9 @@ export const user = sqliteTable("user", { sql`CURRENT_TIMESTAMP` ), generations: integer("generations").default(0), + bio: text("bio"), + personalWebsite: text("personalWebsite"), + links: text("links", { mode: "json" }).default("[]").$type(), tier: text("tier", { enum: ["FREE", "PRO", "ENTERPRISE"] }).default("FREE"), tierExpiresAt: integer("tierExpiresAt"), lastResetDate: integer("lastResetDate"), diff --git a/frontend/components/profile/index.tsx b/frontend/components/profile/index.tsx index 2d62a72..39ebdee 100644 --- a/frontend/components/profile/index.tsx +++ b/frontend/components/profile/index.tsx @@ -9,19 +9,38 @@ import { CardDescription, CardTitle, } from "@/components/ui/card" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" 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 { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" import { deleteSandbox, updateSandbox, updateUser } from "@/lib/actions" +import { socialIcons } from "@/lib/data" +import { editUserSchema, EditUserSchema } from "@/lib/schema" import { TIERS } from "@/lib/tiers" -import { SandboxWithLiked, User } from "@/lib/types" +import { SandboxWithLiked, User, UserLink } from "@/lib/types" +import { cn, parseSocialLink } from "@/lib/utils" import { useUser } from "@clerk/nextjs" +import { zodResolver } from "@hookform/resolvers/zod" import { Edit, + Globe, Heart, Info, Loader2, @@ -29,17 +48,27 @@ import { Package2, PlusCircle, Sparkles, + Trash2, X, } from "lucide-react" import { useRouter } from "next/navigation" -import { Fragment, useCallback, useEffect, useMemo, useState } from "react" +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition, +} from "react" import { useFormState, useFormStatus } from "react-dom" +import { useFieldArray, useForm } from "react-hook-form" import { toast } from "sonner" import Avatar from "../ui/avatar" import { Badge } from "../ui/badge" import { Input } from "../ui/input" import { Progress } from "../ui/progress" - +import { Textarea } from "../ui/textarea" // #region Profile Page export default function ProfilePage({ publicSandboxes, @@ -75,6 +104,9 @@ export default function ProfilePage({ generations={isOwnProfile ? loggedInUser.generations : undefined} isOwnProfile={isOwnProfile} tier={profileOwner.tier} + bio={profileOwner.bio} + personalWebsite={profileOwner.personalWebsite} + socialLinks={profileOwner.links} />
@@ -101,11 +133,17 @@ function ProfileCard({ joinedDate, generations, isOwnProfile, + bio, + personalWebsite, + socialLinks, tier, }: { name: string username: string avatarUrl: string | null + bio: string | null + personalWebsite: string | null + socialLinks: UserLink[] sandboxes: SandboxWithLiked[] joinedDate: Date generations?: number @@ -113,9 +151,8 @@ function ProfileCard({ tier: string }) { 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", @@ -140,117 +177,331 @@ function ProfileCard({ } }, [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]) + const showAddMoreInfoBanner = useMemo(() => { + return !bio && !personalWebsite && socialLinks.length === 0 + }, [personalWebsite, bio, socialLinks]) + return ( {isOwnProfile && ( - +
+ + + + + + +

+ {showAddMoreInfoBanner + ? "Add more information to your profile" + : "Edit your profile"} +

+
+
+
+
)} - {!isEditing ? ( -
- {name} - {`@${username}`} -
+ {isEditing ? ( + ) : ( -
- - -
- - -
- -
- -
- - - @ - +
+
+
+ {name} + {`@${username}`}
+ {typeof generations === "number" && ( +
+ +
+ )}
- - - - )} - {!isEditing && ( - <> -
+
-
-

{joinedAt}

- {typeof generations === "number" && ( - - )} -
- + {bio &&

{bio}

} + {(socialLinks.length > 0 || personalWebsite) && ( +
+ {personalWebsite && ( + + )} + {socialLinks.map((link, index) => { + const Icon = socialIcons[link.platform] + return ( + + ) + })} +
+ )} +

+ {joinedAt} +

+
)} ) } -function SubmitButton() { - const { pending } = useFormStatus() - +function EditProfileForm(props: { + name: string + username: string + avatarUrl: string | null + bio: string | null + personalWebsite: string | null + socialLinks: UserLink[] + toggleEdit: () => void +}) { + const router = useRouter() + const { user } = useUser() + const formRef = useRef(null) + const [formState, formAction] = useFormState(updateUser, { + message: "", + }) + const [isPending, startTransition] = useTransition() + const { name, username, bio, personalWebsite, socialLinks, toggleEdit } = + props + const form = useForm({ + resolver: zodResolver(editUserSchema), + defaultValues: { + oldUsername: username, + id: user?.id, + name, + username, + bio: bio ?? "", + personalWebsite: personalWebsite ?? "", + links: + socialLinks.length > 0 + ? socialLinks + : [{ url: "", platform: "generic" }], + ...(formState.fields ?? {}), + }, + }) + const { fields, append, remove } = useFieldArray({ + name: "links", + control: form.control, + }) + useEffect(() => { + const message = formState.message + if (!Boolean(message)) return + if ("error" in formState) { + toast.error(formState.message) + return + } + toast.success(formState.message as String) + toggleEdit() + if (formState?.newRoute) { + router.replace(formState.newRoute) + } + }, [formState]) return ( -