741 lines
22 KiB
TypeScript
741 lines
22 KiB
TypeScript
"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 {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@/components/ui/form"
|
|
import {
|
|
HoverCard,
|
|
HoverCardContent,
|
|
HoverCardTrigger,
|
|
} from "@/components/ui/hover-card"
|
|
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, 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,
|
|
LucideIcon,
|
|
Package2,
|
|
PlusCircle,
|
|
Sparkles,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react"
|
|
import { useRouter } from "next/navigation"
|
|
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,
|
|
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 (
|
|
<>
|
|
<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}
|
|
tier={profileOwner.tier}
|
|
bio={profileOwner.bio}
|
|
personalWebsite={profileOwner.personalWebsite}
|
|
socialLinks={profileOwner.links}
|
|
/>
|
|
</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,
|
|
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
|
|
isOwnProfile: boolean
|
|
tier: string
|
|
}) {
|
|
const { user } = useUser()
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
|
|
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])
|
|
|
|
const showAddMoreInfoBanner = useMemo(() => {
|
|
return !bio && !personalWebsite && (socialLinks?.length ?? 0) === 0
|
|
}, [personalWebsite, bio, socialLinks])
|
|
|
|
return (
|
|
<Card className="mb-6 md:mb-0 sticky top-6">
|
|
{isOwnProfile && (
|
|
<div className="absolute top-2 right-2 flex flex-col gap-2">
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
onClick={toggleEdit}
|
|
aria-label={isEditing ? "close edit form" : "open edit form"}
|
|
size="smIcon"
|
|
variant="secondary"
|
|
className="rounded-full relative"
|
|
>
|
|
{isEditing ? (
|
|
<X className="size-4" />
|
|
) : showAddMoreInfoBanner ? (
|
|
<>
|
|
<Sparkles className="size-4 text-yellow-400 z-[2]" />
|
|
<div className="z-[1] absolute inset-0 rounded-full bg-secondary animate-ping" />
|
|
</>
|
|
) : (
|
|
<Edit className="size-4" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
{showAddMoreInfoBanner
|
|
? "Add more information to your profile"
|
|
: "Edit your profile"}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
)}
|
|
<CardContent className="flex flex-col gap-4 items-center pt-6">
|
|
<Avatar name={name} avatarUrl={avatarUrl} className="size-36" />
|
|
|
|
{isEditing ? (
|
|
<EditProfileForm
|
|
{...{
|
|
name,
|
|
username,
|
|
avatarUrl,
|
|
bio,
|
|
personalWebsite,
|
|
socialLinks,
|
|
toggleEdit,
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col gap-2.5 items-center">
|
|
<div className="space-y-1.5">
|
|
<div className="">
|
|
<CardTitle className="text-2xl text-center">{name}</CardTitle>
|
|
<CardDescription className="text-center">{`@${username}`}</CardDescription>
|
|
</div>
|
|
{typeof generations === "number" && (
|
|
<div className="flex justify-center">
|
|
<SubscriptionBadge
|
|
generations={generations}
|
|
tier={tier as keyof typeof TIERS}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-4">
|
|
<StatsItem icon={Package2} label={stats.sandboxes} />
|
|
<StatsItem icon={Heart} label={stats.likes} />
|
|
</div>
|
|
{bio && <p className="text-sm text-center">{bio}</p>}
|
|
{(socialLinks.length > 0 || personalWebsite) && (
|
|
<div className="flex gap-2 justify-center">
|
|
{personalWebsite && (
|
|
<Button variant="ghost" size="smIcon" asChild>
|
|
<a
|
|
href={personalWebsite}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<Globe className="size-4" />
|
|
<span className="sr-only">Personal Website</span>
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{socialLinks.map((link, index) => {
|
|
const Icon = socialIcons[link.platform]
|
|
return (
|
|
<Button key={index} variant="ghost" size="smIcon" asChild>
|
|
<a
|
|
href={link.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<Icon className="size-4" />
|
|
<span className="sr-only">{link.platform}</span>
|
|
</a>
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
<p className="text-xs mt-2 text-muted-foreground text-center">
|
|
{joinedAt}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
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<HTMLFormElement>(null)
|
|
const [formState, formAction] = useFormState(updateUser, {
|
|
message: "",
|
|
})
|
|
const [isPending, startTransition] = useTransition()
|
|
const { name, username, bio, personalWebsite, socialLinks, toggleEdit } =
|
|
props
|
|
const form = useForm<EditUserSchema>({
|
|
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 (
|
|
<Form {...form}>
|
|
<form
|
|
ref={formRef}
|
|
action={formAction}
|
|
onSubmit={(evt) => {
|
|
evt.preventDefault()
|
|
form.handleSubmit(() => {
|
|
startTransition(() => {
|
|
formAction(new FormData(formRef.current!))
|
|
})
|
|
})(evt)
|
|
}}
|
|
className="space-y-3 w-full"
|
|
>
|
|
<input type="hidden" name="id" value={user?.id} />
|
|
<input type="hidden" name="oldUsername" value={username} />
|
|
<FormField
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Name</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="marie doe" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="username"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>User name</FormLabel>
|
|
<FormControl>
|
|
<div className="relative">
|
|
<Input
|
|
className="peer ps-6"
|
|
type="text"
|
|
placeholder="Username"
|
|
{...field}
|
|
/>
|
|
<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>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="bio"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Bio</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="hi, I love building things!"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="personalWebsite"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Personal Website</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="https://chillguy.dev" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<div>
|
|
{fields.map((field, index) => (
|
|
<FormField
|
|
control={form.control}
|
|
key={field.id}
|
|
name={`links.${index}`}
|
|
render={({ field: { onChange, value, ...field } }) => {
|
|
const Icon = socialIcons[value.platform] ?? socialIcons.generic
|
|
return (
|
|
<FormItem>
|
|
<FormLabel className={cn(index !== 0 && "sr-only")}>
|
|
Social Links
|
|
</FormLabel>
|
|
<FormDescription className={cn(index !== 0 && "sr-only")}>
|
|
Add links to your blogs or social media profiles.
|
|
</FormDescription>
|
|
<FormControl>
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<Input
|
|
{...field}
|
|
className="peer ps-9"
|
|
value={value.url}
|
|
onChange={(e) =>
|
|
onChange(parseSocialLink(e.currentTarget.value))
|
|
}
|
|
/>
|
|
<div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50">
|
|
<Icon
|
|
size={16}
|
|
strokeWidth={2}
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="smIcon"
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => remove(index)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)
|
|
}}
|
|
/>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-2"
|
|
onClick={() => append({ url: "", platform: "generic" })}
|
|
>
|
|
Add URL
|
|
</Button>
|
|
</div>
|
|
<SubmitButton {...{ isPending }} />
|
|
</form>
|
|
</Form>
|
|
)
|
|
}
|
|
function SubmitButton({ isPending }: { isPending: boolean }) {
|
|
const formStatus = useFormStatus()
|
|
const { pending } = formStatus
|
|
const pend = pending || isPending
|
|
return (
|
|
<Button size="sm" type="submit" className="w-full mt-2" disabled={pend}>
|
|
{pend && <Loader2 className="animate-spin mr-2 h-4 w-4" />}
|
|
Save Changes
|
|
</Button>
|
|
)
|
|
}
|
|
// #endregion
|
|
|
|
// #region Sandboxes Panel
|
|
function SandboxesPanel({
|
|
publicSandboxes,
|
|
privateSandboxes,
|
|
isOwnProfile,
|
|
}: {
|
|
publicSandboxes: SandboxWithLiked[]
|
|
privateSandboxes: SandboxWithLiked[]
|
|
isOwnProfile: boolean
|
|
}) {
|
|
const [deletingId, setDeletingId] = useState<string>("")
|
|
const hasPublicSandboxes = publicSandboxes.length > 0
|
|
const hasPrivateSandboxes = privateSandboxes.length > 0
|
|
|
|
const onVisibilityChange = useMemo(
|
|
() =>
|
|
async (sandbox: Pick<SandboxWithLiked, "id" | "name" | "visibility">) => {
|
|
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<SandboxWithLiked, "id" | "name">) => {
|
|
setDeletingId(sandbox.id)
|
|
toast(`Project ${sandbox.name} deleted.`)
|
|
await deleteSandbox(sandbox.id)
|
|
setDeletingId("")
|
|
},
|
|
[]
|
|
)
|
|
if (!isOwnProfile) {
|
|
return (
|
|
<div className="">
|
|
{hasPublicSandboxes ? (
|
|
<>
|
|
<h2 className="font-semibold text-xl mb-4">Sandboxes</h2>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{publicSandboxes.map((sandbox) => {
|
|
return (
|
|
<Fragment key={sandbox.id}>
|
|
{isOwnProfile ? (
|
|
<ProjectCard
|
|
onVisibilityChange={onVisibilityChange}
|
|
onDelete={onDelete}
|
|
deletingId={deletingId}
|
|
isAuthenticated
|
|
{...sandbox}
|
|
/>
|
|
) : (
|
|
<ProjectCard isAuthenticated={false} {...sandbox} />
|
|
)}
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<EmptyState type="private" isOwnProfile={isOwnProfile} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<Tabs defaultValue="public">
|
|
<TabsList className="mb-4">
|
|
<TabsTrigger value="public">Public</TabsTrigger>
|
|
<TabsTrigger value="private">Private</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="public">
|
|
{hasPublicSandboxes ? (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{publicSandboxes.map((sandbox) => {
|
|
return (
|
|
<Fragment key={sandbox.id}>
|
|
{isOwnProfile ? (
|
|
<ProjectCard
|
|
onVisibilityChange={onVisibilityChange}
|
|
onDelete={onDelete}
|
|
deletingId={deletingId}
|
|
isAuthenticated
|
|
{...sandbox}
|
|
/>
|
|
) : (
|
|
<ProjectCard isAuthenticated={false} {...sandbox} />
|
|
)}
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<EmptyState type="public" isOwnProfile={isOwnProfile} />
|
|
)}
|
|
</TabsContent>
|
|
<TabsContent value="private">
|
|
{hasPrivateSandboxes ? (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{privateSandboxes.map((sandbox) => (
|
|
<ProjectCard
|
|
key={sandbox.id}
|
|
onVisibilityChange={onVisibilityChange}
|
|
onDelete={onDelete}
|
|
deletingId={deletingId}
|
|
isAuthenticated
|
|
{...sandbox}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState type="private" isOwnProfile={isOwnProfile} />
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
)
|
|
}
|
|
// #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 (
|
|
<>
|
|
<Card className="flex flex-col items-center justify-center p-6 text-center h-[300px]">
|
|
<PlusCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<CardTitle className="text-xl mb-2">{text.title}</CardTitle>
|
|
<CardDescription className="mb-4">{text.description}</CardDescription>
|
|
{isOwnProfile && (
|
|
<Button onClick={openModal}>
|
|
<PlusCircle className="h-4 w-4 mr-2" />
|
|
Create Sandbox
|
|
</Button>
|
|
)}
|
|
</Card>
|
|
<NewProjectModal
|
|
open={newProjectModalOpen}
|
|
setOpen={setNewProjectModalOpen}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
// #endregion
|
|
|
|
// #region StatsItem
|
|
interface StatsItemProps {
|
|
icon: LucideIcon
|
|
label: string
|
|
}
|
|
|
|
const StatsItem = ({ icon: Icon, label }: StatsItemProps) => (
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={16} />
|
|
<span className="text-sm text-muted-foreground">{label}</span>
|
|
</div>
|
|
)
|
|
// #endregion
|
|
|
|
// #region Sub Badge
|
|
const SubscriptionBadge = ({
|
|
generations,
|
|
tier = "FREE",
|
|
}: {
|
|
generations: number
|
|
tier?: keyof typeof TIERS
|
|
}) => {
|
|
return (
|
|
<div className="flex gap-2 items-center">
|
|
<Badge variant="secondary" className="text-sm cursor-pointer">
|
|
{tier}
|
|
</Badge>
|
|
<HoverCard>
|
|
<HoverCardTrigger>
|
|
<Button variant="ghost" size="smIcon">
|
|
<Info size={16} />
|
|
</Button>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent>
|
|
<div className="w-full space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="font-medium">AI Generations</span>
|
|
<span>{`${generations} / ${TIERS[tier].generations}`}</span>
|
|
</div>
|
|
<Progress
|
|
value={generations}
|
|
max={TIERS[tier].generations}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
<Button size="sm" className="w-full mt-4">
|
|
<Sparkles className="mr-2 h-4 w-4" /> Upgrade to Pro
|
|
</Button>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</div>
|
|
)
|
|
}
|
|
// #endregion
|