From 024e30bd99d78f9ef7e10be37ea182826e2c4dd3 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 6 Jan 2025 02:51:24 +0100 Subject: [PATCH 1/3] feat: update backend schema and code for new profile UI --- backend/database/src/index.ts | 18 +++++++++++------- backend/database/src/schema.ts | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/backend/database/src/index.ts b/backend/database/src/index.ts index 80e38e4..cd1da76 100644 --- a/backend/database/src/index.ts +++ b/backend/database/src/index.ts @@ -5,13 +5,7 @@ import { z } from "zod" import { and, eq, sql } from "drizzle-orm" import * as schema from "./schema" -import { - Sandbox, - sandbox, - sandboxLikes, - user, - usersToSandboxes, -} from "./schema" +import { Sandbox, sandbox, user, usersToSandboxes } from "./schema" export interface Env { DB: D1Database @@ -365,6 +359,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 33c86f3..c490a59 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") @@ -16,6 +36,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"), From ceeb1fbce3daeef1d07c4aa71e93f431014b4a5b Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 6 Jan 2025 02:52:32 +0100 Subject: [PATCH 2/3] feat: complete new UI for Profiles, fix notfound error on username change --- frontend/components/profile/index.tsx | 451 ++++++++++++++++++++------ frontend/components/ui/form.tsx | 16 +- frontend/components/ui/textarea.tsx | 22 ++ frontend/components/ui/tooltip.tsx | 4 +- frontend/lib/actions.ts | 58 ++-- frontend/lib/constants/index.ts | 14 + frontend/lib/data/index.ts | 34 ++ frontend/lib/schema/index.ts | 20 ++ frontend/lib/types.ts | 17 +- frontend/lib/utils.ts | 56 +++- frontend/package-lock.json | 343 +++++++++++++------- frontend/package.json | 12 +- 12 files changed, 784 insertions(+), 263 deletions(-) create mode 100644 frontend/components/ui/textarea.tsx create mode 100644 frontend/lib/constants/index.ts create mode 100644 frontend/lib/schema/index.ts 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 ( -