From 21e4bfb735ccc12562ee8b8916f51b3e2ec959b2 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 4 Nov 2024 21:49:30 +0100 Subject: [PATCH 1/8] feat: add profile page WIP --- frontend/app/[userId]/page.tsx | 72 +++++ frontend/components/profile/index.tsx | 186 ++++++++++++ frontend/components/ui/badge.tsx | 36 +++ frontend/components/ui/tabs.tsx | 55 ++++ frontend/package-lock.json | 405 ++++++++++++++++++++++++++ frontend/package.json | 2 + 6 files changed, 756 insertions(+) create mode 100644 frontend/app/[userId]/page.tsx create mode 100644 frontend/components/profile/index.tsx create mode 100644 frontend/components/ui/badge.tsx create mode 100644 frontend/components/ui/tabs.tsx diff --git a/frontend/app/[userId]/page.tsx b/frontend/app/[userId]/page.tsx new file mode 100644 index 0000000..0184d98 --- /dev/null +++ b/frontend/app/[userId]/page.tsx @@ -0,0 +1,72 @@ +import Logo from "@/assets/logo.svg" +import ProfilePage from "@/components/profile" +import { ThemeSwitcher } from "@/components/ui/theme-switcher" +import UserButton from "@/components/ui/userButton" +import { Sandbox, User } from "@/lib/types" +import { currentUser } from "@clerk/nextjs" +import Image from "next/image" +import Link from "next/link" + +export default async function Page({ + params: { userId }, +}: { + params: { userId: string } +}) { + const [userRes, user] = await Promise.all([ + fetch( + `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${userId}`, + { + headers: { + Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, + }, + } + ), + currentUser(), + ]) + + const userData = (await userRes.json()) as User + const publicSandboxes: Sandbox[] = [] + const privateSandboxes: Sandbox[] = [] + + userData.sandbox.forEach((sandbox) => { + if (sandbox.visibility === "public") { + publicSandboxes.push(sandbox) + } else if (sandbox.visibility === "private") { + privateSandboxes.push(sandbox) + } + }) + + return ( +
+
+
+ + Logo + +
Sandbox
+
+
+ + {Boolean(userData) ? : null} +
+
+ +
+ ) +} diff --git a/frontend/components/profile/index.tsx b/frontend/components/profile/index.tsx new file mode 100644 index 0000000..a95e440 --- /dev/null +++ b/frontend/components/profile/index.tsx @@ -0,0 +1,186 @@ +"use client" + +import ProjectCard from "@/components/dashboard/projectCard/" +import { CanvasRevealEffect } from "@/components/dashboard/projectCard/revealEffect" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardTitle, +} from "@/components/ui/card" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { updateSandbox } from "@/lib/actions" +import { Sandbox, User } from "@/lib/types" +import { PlusCircle } from "lucide-react" +import Link from "next/link" +import { toast } from "sonner" +const colors: { [key: string]: number[][] } = { + react: [ + [71, 207, 237], + [30, 126, 148], + ], + node: [ + [86, 184, 72], + [59, 112, 52], + ], +} + +export default function ProfilePage({ + publicSandboxes, + privateSandboxes, + user, + currentUser, +}: { + publicSandboxes: Sandbox[] + privateSandboxes: Sandbox[] + user: User + currentUser: { + id: string + firstName: string | null + lastName: string | null + } | null +}) { + 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, + }) + } + const isLoggedIn = Boolean(currentUser) + const hasPublicSandboxes = publicSandboxes.length > 0 + const hasPrivateSandboxes = privateSandboxes.length > 0 + return ( + <> +
+
+ + +
+ + {user.name && + user.name + .split(" ") + .slice(0, 2) + .map((name) => name[0].toUpperCase())} + +
+ {user.name} + @janedoe +

+ Full-stack developer | Open source enthusiast +

+

+ Joined January 2023 +

+
+
+
+
+
+

Sandboxes

+
+ + + + Public + {isLoggedIn && Private} + + + {hasPublicSandboxes ? ( +
+ {publicSandboxes.map((sandbox) => { + return ( + + {}} + deletingId={"deletingId"} + > + +
+ + + ) + })} +
+ ) : ( + + )} + + {isLoggedIn && ( + + {hasPrivateSandboxes ? ( +
+ {privateSandboxes.map((sandbox) => ( + + {}} + deletingId={"deletingId"} + > + +
+ + + ))} +
+ ) : ( + + )} + + )} + +
+
+ + ) +} + +function EmptyState({ + title, + description, +}: { + title: string + description: string +}) { + return ( + + + {title} + {description} + + + ) +} diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..e87d62b --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } 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/package-lock.json b/frontend/package-lock.json index 91f14a5..08e15bb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@monaco-editor/react": "^4.6.0", "@paralleldrive/cuid2": "^2.2.2", "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -29,6 +30,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.1.1", "@react-three/fiber": "^8.16.6", "@uiw/codemirror-theme-vscode": "^4.23.5", "@uiw/react-codemirror": "^4.23.5", @@ -947,6 +949,126 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.1.tgz", + "integrity": "sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", @@ -1911,6 +2033,289 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", + "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 633bc81..3e1035c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@monaco-editor/react": "^4.6.0", "@paralleldrive/cuid2": "^2.2.2", "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -30,6 +31,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.1.1", "@react-three/fiber": "^8.16.6", "@uiw/codemirror-theme-vscode": "^4.23.5", "@uiw/react-codemirror": "^4.23.5", From e763caf389a5e70a1f5b01bd3054caf21fd38592 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 11 Nov 2024 22:00:15 +0100 Subject: [PATCH 2/8] feat: get user by username --- backend/database/src/index.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/database/src/index.ts b/backend/database/src/index.ts index bba033d..b029045 100644 --- a/backend/database/src/index.ts +++ b/backend/database/src/index.ts @@ -274,6 +274,18 @@ export default { }, }) return json(res ?? {}) + } else if (params.has("username")) { + const username = params.get("username") as string + const res = await db.query.user.findFirst({ + where: (user, { eq }) => eq(user.username, username), + with: { + sandbox: { + orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)], + }, + usersToSandboxes: true, + }, + }) + return json(res ?? {}) } else { const res = await db.select().from(user).all() return json(res ?? {}) @@ -290,7 +302,8 @@ export default { }) const body = await request.json() - const { id, name, email, username, avatarUrl, createdAt, generations } = userSchema.parse(body) + const { id, name, email, username, avatarUrl, createdAt, generations } = + userSchema.parse(body) const res = await db .insert(user) @@ -324,7 +337,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 }) From aed9742a4dc3dcb89234161a2ab371d5fdfe5c35 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 11 Nov 2024 22:01:31 +0100 Subject: [PATCH 3/8] chore: format document --- frontend/app/(app)/layout.tsx | 42 ++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/frontend/app/(app)/layout.tsx b/frontend/app/(app)/layout.tsx index f4a5a5b..7c236a3 100644 --- a/frontend/app/(app)/layout.tsx +++ b/frontend/app/(app)/layout.tsx @@ -1,7 +1,7 @@ import { User } from "@/lib/types" +import { generateUniqueUsername } from "@/lib/username-generator" import { currentUser } from "@clerk/nextjs" import { redirect } from "next/navigation" -import { generateUniqueUsername } from "@/lib/username-generator"; export default async function AppAuthLayout({ children, @@ -27,22 +27,24 @@ export default async function AppAuthLayout({ if (!dbUserJSON.id) { // Try to get GitHub username if available const githubUsername = user.externalAccounts.find( - account => account.provider === "github" - )?.username; + (account) => account.provider === "github" + )?.username - const username = githubUsername || await generateUniqueUsername(async (username) => { - // Check if username exists in database - const userCheck = await fetch( - `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user/check-username?username=${username}`, - { - headers: { - Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, - }, - } - ) - const exists = await userCheck.json() - return exists.exists - }); + const username = + githubUsername || + (await generateUniqueUsername(async (username) => { + // Check if username exists in database + const userCheck = await fetch( + `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user/check-username?username=${username}`, + { + headers: { + Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, + }, + } + ) + const exists = await userCheck.json() + return exists.exists + })) const res = await fetch( `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user`, @@ -64,11 +66,11 @@ export default async function AppAuthLayout({ ) if (!res.ok) { - const error = await res.text(); - console.error("Failed to create user:", error); + const error = await res.text() + console.error("Failed to create user:", error) } else { - const data = await res.json(); - console.log("User created successfully:", data); + const data = await res.json() + console.log("User created successfully:", data) } } From 00e51205cff0d0a95f40f0f674e616bee76dd3fd Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 11 Nov 2024 22:01:58 +0100 Subject: [PATCH 4/8] chore: rename folder to use username instead of id --- frontend/app/[userId]/page.tsx | 72 ---------------------------------- 1 file changed, 72 deletions(-) delete mode 100644 frontend/app/[userId]/page.tsx diff --git a/frontend/app/[userId]/page.tsx b/frontend/app/[userId]/page.tsx deleted file mode 100644 index 0184d98..0000000 --- a/frontend/app/[userId]/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import Logo from "@/assets/logo.svg" -import ProfilePage from "@/components/profile" -import { ThemeSwitcher } from "@/components/ui/theme-switcher" -import UserButton from "@/components/ui/userButton" -import { Sandbox, User } from "@/lib/types" -import { currentUser } from "@clerk/nextjs" -import Image from "next/image" -import Link from "next/link" - -export default async function Page({ - params: { userId }, -}: { - params: { userId: string } -}) { - const [userRes, user] = await Promise.all([ - fetch( - `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${userId}`, - { - headers: { - Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, - }, - } - ), - currentUser(), - ]) - - const userData = (await userRes.json()) as User - const publicSandboxes: Sandbox[] = [] - const privateSandboxes: Sandbox[] = [] - - userData.sandbox.forEach((sandbox) => { - if (sandbox.visibility === "public") { - publicSandboxes.push(sandbox) - } else if (sandbox.visibility === "private") { - privateSandboxes.push(sandbox) - } - }) - - return ( -
-
-
- - Logo - -
Sandbox
-
-
- - {Boolean(userData) ? : null} -
-
- -
- ) -} From 105eab9bada7c2be8b9cfcca1558c24a8d78ba0f Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 11 Nov 2024 22:02:34 +0100 Subject: [PATCH 5/8] feat: complete profile page --- frontend/app/[username]/page.tsx | 59 ++ .../components/dashboard/navbar/index.tsx | 2 +- .../dashboard/projectCard/dropdown.tsx | 14 +- .../dashboard/projectCard/index.tsx | 237 +++-- frontend/components/dashboard/projects.tsx | 57 +- frontend/components/profile/index.tsx | 247 +++-- frontend/components/profile/navbar.tsx | 26 + frontend/components/ui/avatar.tsx | 7 +- frontend/components/ui/hover-card.tsx | 29 + frontend/components/ui/progress.tsx | 4 +- frontend/components/ui/theme-switcher.tsx | 2 +- frontend/components/ui/userButton.tsx | 55 +- frontend/lib/constant.ts | 1 + frontend/lib/types.ts | 2 + frontend/package-lock.json | 912 ++++++++++++------ frontend/package.json | 3 +- 16 files changed, 1172 insertions(+), 485 deletions(-) create mode 100644 frontend/app/[username]/page.tsx create mode 100644 frontend/components/profile/navbar.tsx create mode 100644 frontend/components/ui/hover-card.tsx create mode 100644 frontend/lib/constant.ts diff --git a/frontend/app/[username]/page.tsx b/frontend/app/[username]/page.tsx new file mode 100644 index 0000000..9b1d2d1 --- /dev/null +++ b/frontend/app/[username]/page.tsx @@ -0,0 +1,59 @@ +import ProfilePage from "@/components/profile" +import ProfileNavbar from "@/components/profile/navbar" +import { Sandbox, User } from "@/lib/types" +import { currentUser } from "@clerk/nextjs" + +export default async function Page({ + params: { username: rawUsername }, +}: { + params: { username: string } +}) { + const username = decodeURIComponent(rawUsername).replace("@", "") + const currentLoggedInUser = await currentUser() + console.log(username) + const [profileRespnse, dbUserResponse] = await Promise.all([ + fetch( + `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?username=${username}`, + { + headers: { + Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, + }, + } + ), + fetch( + `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${currentLoggedInUser?.id}`, + { + headers: { + Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, + }, + } + ), + ]) + + const userProfile = (await profileRespnse.json()) as User + const dbUserData = (await dbUserResponse.json()) as User + const publicSandboxes: Sandbox[] = [] + const privateSandboxes: Sandbox[] = [] + + userProfile?.sandbox?.forEach((sandbox) => { + if (sandbox.visibility === "public") { + publicSandboxes.push(sandbox) + } else if (sandbox.visibility === "private") { + privateSandboxes.push(sandbox) + } + }) + const hasCurrentUser = Boolean(dbUserData?.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 ( @@ -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..c2c2886 100644 --- a/frontend/components/dashboard/projectCard/index.tsx +++ b/frontend/components/dashboard/projectCard/index.tsx @@ -4,56 +4,154 @@ import { Card } from "@/components/ui/card" import { projectTemplates } from "@/lib/data" import { Sandbox } from "@/lib/types" 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 { useRouter } from "next/navigation" -import { useEffect, useState } from "react" +import { memo, useEffect, useMemo, useState } 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 + 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( + ({ + visibility, + createdAt, + likeCount, + viewCount, + }: Pick< + BaseProjectCardProps, + "visibility" | "createdAt" | "likeCount" | "viewCount" + >) => { + const [date, setDate] = useState() + + useEffect(() => { + setDate(formatDate(new Date(createdAt))) + }, [createdAt]) + + return ( +
+
+
+ {visibility === "private" ? ( + <> + Private + + ) : ( + <> + Public + + )} +
+
+
+
+ {date} +
+ + +
+
+ ) + } +) + +ProjectMetadata.displayName = "ProjectMetadata" + +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" : ""} + `} > {hovered && ( @@ -62,38 +160,59 @@ export default function ProjectCard({ animate={{ opacity: 1 }} className="h-full w-full absolute inset-0" > - {children} + +
)}
- -
- {sandbox.name} -
- -
-
-
- {sandbox.visibility === "private" ? ( - <> - Private - - ) : ( - <> - Public - - )} -
-
- {date} +
+ {name}
+ {props.isAuthenticated && ( + + )}
+ + ) } + +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..f06746a 100644 --- a/frontend/components/dashboard/projects.tsx +++ b/frontend/components/dashboard/projects.tsx @@ -2,11 +2,11 @@ import { deleteSandbox, updateSandbox } from "@/lib/actions" import { Sandbox } from "@/lib/types" +import { cn } from "@/lib/utils" 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 +28,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 +56,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 (
@@ -67,26 +74,20 @@ export default function DashboardProjects({ - -
- + isAuthenticated + {...sandbox} + /> ) })} diff --git a/frontend/components/profile/index.tsx b/frontend/components/profile/index.tsx index a95e440..632ee9e 100644 --- a/frontend/components/profile/index.tsx +++ b/frontend/components/profile/index.tsx @@ -1,7 +1,6 @@ "use client" import ProjectCard from "@/components/dashboard/projectCard/" -import { CanvasRevealEffect } from "@/components/dashboard/projectCard/revealEffect" import { Button } from "@/components/ui/button" import { Card, @@ -9,22 +8,23 @@ import { CardDescription, CardTitle, } from "@/components/ui/card" +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { updateSandbox } from "@/lib/actions" +import { deleteSandbox, updateSandbox } from "@/lib/actions" +import { MAX_FREE_GENERATION } from "@/lib/constant" import { Sandbox, User } from "@/lib/types" -import { PlusCircle } from "lucide-react" +import { cn } from "@/lib/utils" +import { Heart, LucideIcon, Package2, PlusCircle, Sparkles } from "lucide-react" import Link from "next/link" +import { useMemo, useState } from "react" import { toast } from "sonner" -const colors: { [key: string]: number[][] } = { - react: [ - [71, 207, 237], - [30, 126, 148], - ], - node: [ - [86, 184, 72], - [59, 112, 52], - ], -} +import Avatar from "../ui/avatar" +import { Badge } from "../ui/badge" +import { Progress } from "../ui/progress" export default function ProfilePage({ publicSandboxes, @@ -35,54 +35,89 @@ export default function ProfilePage({ publicSandboxes: Sandbox[] privateSandboxes: Sandbox[] user: User - currentUser: { - id: string - firstName: string | null - lastName: string | null - } | null + currentUser: User | null }) { - 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, - }) - } + const [deletingId, setDeletingId] = useState("") const isLoggedIn = Boolean(currentUser) 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("") + }, + [] + ) + const stats = useMemo(() => { + 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 ( <>
-
- - {user.name && - user.name - .split(" ") - .slice(0, 2) - .map((name) => name[0].toUpperCase())} - -
+ + {user.name} - @janedoe -

- Full-stack developer | Open source enthusiast -

-

- Joined January 2023 -

+ {`@${user.username}`} +
+ + +
+
+

+ {`Joined ${joinDate}`} +

+ {isLoggedIn && } +
-
-

Sandboxes

-
- Public @@ -96,22 +131,24 @@ export default function ProfilePage({ - {}} - deletingId={"deletingId"} - > - -
- + ) : ( + + )} ) })} @@ -119,7 +156,12 @@ export default function ProfilePage({ ) : ( )} @@ -131,29 +173,32 @@ export default function ProfilePage({ {}} - deletingId={"deletingId"} - > - -
- + onDelete={onDelete} + deletingId={deletingId} + isAuthenticated + {...sandbox} + /> ))}
) : ( )} @@ -168,19 +213,63 @@ export default function ProfilePage({ function EmptyState({ title, description, + isLoggedIn, }: { title: string description: string + isLoggedIn: boolean }) { return ( {title} {description} - + {isLoggedIn && ( + + )} ) } + +interface StatsItemProps { + icon: LucideIcon + label: string +} + +const StatsItem = ({ icon: Icon, label }: StatsItemProps) => ( +
+ + {label} +
+) + +const SubscriptionBadge = ({ user }: { user: User }) => { + return ( + + + + Free + + + +
+
+ AI Generations + {`${user.generations} / ${MAX_FREE_GENERATION}`} +
+ +
+ +
+
+ ) +} diff --git a/frontend/components/profile/navbar.tsx b/frontend/components/profile/navbar.tsx new file mode 100644 index 0000000..e4e7d07 --- /dev/null +++ b/frontend/components/profile/navbar.tsx @@ -0,0 +1,26 @@ +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" + +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, + 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 4fc3b47..f0434de 100644 --- a/frontend/components/ui/progress.tsx +++ b/frontend/components/ui/progress.tsx @@ -1,7 +1,7 @@ "use client" -import * as React from "react" import * as ProgressPrimitive from "@radix-ui/react-progress" +import * as React from "react" import { cn } from "@/lib/utils" @@ -18,7 +18,7 @@ const Progress = React.forwardRef< {...props} > 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 ( -
-
-
- - AI Usage: {userData.generations}/1000 + + +
+ {`AI Usage: ${userData.generations}/${MAX_FREE_GENERATION}`} +
+
+
-
-
-
-
- - + + + + + Dashboard + + + + + + + Profile + + + {/* - + Edit Profile */} signOut(() => router.push("/"))} className="!text-destructive cursor-pointer" > - + Log Out diff --git a/frontend/lib/constant.ts b/frontend/lib/constant.ts new file mode 100644 index 0000000..2ef53d7 --- /dev/null +++ b/frontend/lib/constant.ts @@ -0,0 +1 @@ +export const MAX_FREE_GENERATION = 1000 diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index b4bfbdf..32f3246 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -19,6 +19,8 @@ export type Sandbox = { visibility: "public" | "private" createdAt: Date userId: string + likeCount: number + viewCount: number usersToSandboxes: UsersToSandboxes[] } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1b9aaaf..b56cedd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.1.1", @@ -31,8 +32,8 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", - "@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.3", "@react-three/fiber": "^8.16.6", "@uiw/codemirror-theme-vscode": "^4.23.5", "@uiw/react-codemirror": "^4.23.5", @@ -1696,6 +1697,348 @@ } } }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.2.tgz", + "integrity": "sha512-Y5w0qGhysvmqsIy6nQxaPa6mXNKznfoGjOfBgzOjocLxr2XlSjqBMYQQL+FfyogsMuX+m8cZyQGYhJxvxUzO4w==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-arrow": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", + "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-popper": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", + "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" + }, "node_modules/@radix-ui/react-icons": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz", @@ -2304,7 +2647,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.0.tgz", "integrity": "sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==", - "license": "MIT", "dependencies": { "@radix-ui/react-context": "1.1.0", "@radix-ui/react-primitive": "2.0.0" @@ -2516,6 +2858,289 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", + "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.3.tgz", @@ -2921,289 +3546,6 @@ "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "license": "MIT" }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", - "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", - "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", - "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", - "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", - "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", - "dependencies": { - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", - "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", - "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", - "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2a00384..d48b936 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.1.1", @@ -32,8 +33,8 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", - "@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.3", "@react-three/fiber": "^8.16.6", "@uiw/codemirror-theme-vscode": "^4.23.5", "@uiw/react-codemirror": "^4.23.5", From 06a5d46e1f35ef4cb16c2f975861f57a17fc88b4 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 25 Nov 2024 21:53:46 +0100 Subject: [PATCH 6/8] feat: complete profile page with profile edit, project likes and UI updates --- backend/database/src/index.ts | 180 ++++- backend/database/src/schema.ts | 74 ++- frontend/app/[username]/page.tsx | 46 +- .../dashboard/projectCard/dropdown.tsx | 2 +- .../dashboard/projectCard/index.tsx | 123 +++- frontend/components/dashboard/projects.tsx | 26 +- frontend/components/profile/index.tsx | 624 ++++++++++++------ frontend/components/profile/navbar.tsx | 9 +- frontend/lib/actions.ts | 93 +++ frontend/lib/types.ts | 4 +- 10 files changed, 888 insertions(+), 293 deletions(-) diff --git a/backend/database/src/index.ts b/backend/database/src/index.ts index b029045..ff3b095 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( @@ -258,33 +271,147 @@ export default { .get() 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") { if (method === "GET") { const params = url.searchParams 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() @@ -326,6 +453,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 } diff --git a/backend/database/src/schema.ts b/backend/database/src/schema.ts index 5b28ca1..5d40a97 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,18 +12,14 @@ 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), }) 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()) @@ -32,8 +28,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), @@ -43,13 +40,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") @@ -61,6 +68,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 }) => ({ @@ -74,3 +108,5 @@ export const usersToSandboxesRelations = relations( }), }) ) + +// #endregion diff --git a/frontend/app/[username]/page.tsx b/frontend/app/[username]/page.tsx index 9b1d2d1..87e8ef4 100644 --- a/frontend/app/[username]/page.tsx +++ b/frontend/app/[username]/page.tsx @@ -1,7 +1,8 @@ import ProfilePage from "@/components/profile" import ProfileNavbar from "@/components/profile/navbar" -import { Sandbox, User } from "@/lib/types" +import { SandboxWithLiked, User } from "@/lib/types" import { currentUser } from "@clerk/nextjs" +import { notFound } from "next/navigation" export default async function Page({ params: { username: rawUsername }, @@ -9,11 +10,11 @@ export default async function Page({ params: { username: string } }) { const username = decodeURIComponent(rawUsername).replace("@", "") - const currentLoggedInUser = await currentUser() - console.log(username) - const [profileRespnse, dbUserResponse] = await Promise.all([ + const loggedInClerkUser = await currentUser() + + const [profileOwnerResponse, loggedInUserResponse] = await Promise.all([ 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: { Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, @@ -21,7 +22,7 @@ export default async function Page({ } ), 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: { Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, @@ -30,30 +31,35 @@ export default async function Page({ ), ]) - const userProfile = (await profileRespnse.json()) as User - const dbUserData = (await dbUserResponse.json()) as User - const publicSandboxes: Sandbox[] = [] - const privateSandboxes: Sandbox[] = [] + const profileOwner = (await profileOwnerResponse.json()) as User + const loggedInUser = (await loggedInUserResponse.json()) as User - userProfile?.sandbox?.forEach((sandbox) => { + if (!Boolean(profileOwner?.id)) { + notFound() + } + const publicSandboxes: SandboxWithLiked[] = [] + const privateSandboxes: SandboxWithLiked[] = [] + + profileOwner?.sandbox?.forEach((sandbox) => { if (sandbox.visibility === "public") { - publicSandboxes.push(sandbox) + publicSandboxes.push(sandbox as SandboxWithLiked) } else if (sandbox.visibility === "private") { - privateSandboxes.push(sandbox) + privateSandboxes.push(sandbox as SandboxWithLiked) } }) - const hasCurrentUser = Boolean(dbUserData?.id) + + const isUserLoggedIn = Boolean(loggedInUser?.id) return ( -
- +
+ -
+ ) } diff --git a/frontend/components/dashboard/projectCard/dropdown.tsx b/frontend/components/dashboard/projectCard/dropdown.tsx index e75b493..207c9f8 100644 --- a/frontend/components/dashboard/projectCard/dropdown.tsx +++ b/frontend/components/dashboard/projectCard/dropdown.tsx @@ -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" > diff --git a/frontend/components/dashboard/projectCard/index.tsx b/frontend/components/dashboard/projectCard/index.tsx index c2c2886..4c1ec42 100644 --- a/frontend/components/dashboard/projectCard/index.tsx +++ b/frontend/components/dashboard/projectCard/index.tsx @@ -1,13 +1,26 @@ "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, Eye, Globe, Heart, Lock } from "lucide-react" import Image from "next/image" +import Link from "next/link" 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 { CanvasRevealEffect } from "./revealEffect" @@ -18,6 +31,7 @@ type BaseProjectCardProps = { visibility: "public" | "private" createdAt: Date likeCount: number + liked?: boolean viewCount: number } @@ -59,16 +73,19 @@ const formatDate = (date: Date): string => { const ProjectMetadata = memo( ({ + id, visibility, createdAt, likeCount, + liked, viewCount, }: Pick< BaseProjectCardProps, - "visibility" | "createdAt" | "likeCount" | "viewCount" + "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]) @@ -76,23 +93,23 @@ const ProjectMetadata = memo( return (
-
- {visibility === "private" ? ( - <> - Private - - ) : ( - <> - Public - - )} +
+ + + {visibility === "private" ? "Private" : "Public"} +
-
-
- {date} +
+
+ {date}
- +
@@ -102,6 +119,63 @@ const ProjectMetadata = memo( 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, @@ -150,7 +224,11 @@ function ProjectCardComponent({ 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" : ""} + ${ + props.isAuthenticated && props.deletingId === id + ? "opacity-50 pointer-events-none cursor-events-none" + : "cursor-pointer" + } `} > @@ -178,9 +256,12 @@ function ProjectCardComponent({ width={20} height={20} /> -
+ {name} -
+ {props.isAuthenticated && ( ) diff --git a/frontend/components/dashboard/projects.tsx b/frontend/components/dashboard/projects.tsx index f06746a..14618fa 100644 --- a/frontend/components/dashboard/projects.tsx +++ b/frontend/components/dashboard/projects.tsx @@ -2,8 +2,6 @@ import { deleteSandbox, updateSandbox } from "@/lib/actions" import { Sandbox } from "@/lib/types" -import { cn } from "@/lib/utils" -import Link from "next/link" import { useEffect, useMemo, useState } from "react" import { toast } from "sonner" import ProjectCard from "./projectCard" @@ -71,24 +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 index 632ee9e..4246da5 100644 --- a/frontend/components/profile/index.tsx +++ b/frontend/components/profile/index.tsx @@ -1,5 +1,6 @@ "use client" +import NewProjectModal from "@/components/dashboard/newProject" import ProjectCard from "@/components/dashboard/projectCard/" import { Button } from "@/components/ui/button" import { @@ -13,64 +14,118 @@ import { 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 } from "@/lib/actions" +import { deleteSandbox, updateSandbox, updateUser } from "@/lib/actions" import { MAX_FREE_GENERATION } from "@/lib/constant" -import { Sandbox, User } from "@/lib/types" -import { cn } from "@/lib/utils" -import { Heart, LucideIcon, Package2, PlusCircle, Sparkles } from "lucide-react" -import Link from "next/link" -import { useMemo, useState } from "react" +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, - user, - currentUser, + profileOwner, + loggedInUser, }: { - publicSandboxes: Sandbox[] - privateSandboxes: Sandbox[] - user: User - currentUser: User | null + publicSandboxes: SandboxWithLiked[] + privateSandboxes: SandboxWithLiked[] + profileOwner: User + loggedInUser: User | null }) { - const [deletingId, setDeletingId] = useState("") - const isLoggedIn = Boolean(currentUser) - const hasPublicSandboxes = publicSandboxes.length > 0 - const hasPrivateSandboxes = privateSandboxes.length > 0 + const isOwnProfile = profileOwner.id === loggedInUser?.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) - setDeletingId("") - }, - [] - ) - const stats = useMemo(() => { - const allSandboxes = isLoggedIn + const sandboxes = useMemo(() => { + const allSandboxes = isOwnProfile ? [...publicSandboxes, ...privateSandboxes] : publicSandboxes - const totalSandboxes = allSandboxes.length - const totalLikes = allSandboxes.reduce( + 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 ) @@ -80,160 +135,299 @@ export default function ProfilePage({ 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] - ) + }, [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 ( - <> -
-
- - - - - {user.name} - {`@${user.username}`} -
- - -
-
-

- {`Joined ${joinDate}`} -

- {isLoggedIn && } -
-
-
-
-
- - - Public - {isLoggedIn && Private} - - - {hasPublicSandboxes ? ( -
- {publicSandboxes.map((sandbox) => { - return ( - - {isLoggedIn ? ( - - ) : ( - - )} - - ) - })} -
- ) : ( - - )} -
- {isLoggedIn && ( - - {hasPrivateSandboxes ? ( -
- {privateSandboxes.map((sandbox) => ( - - - - ))} -
- ) : ( - - )} -
- )} -
-
-
- - ) -} - -function EmptyState({ - title, - description, - isLoggedIn, -}: { - title: string - description: string - isLoggedIn: boolean -}) { - return ( - - - {title} - {description} - {isLoggedIn && ( - )} + + + + {!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 @@ -245,31 +439,39 @@ const StatsItem = ({ icon: Icon, label }: StatsItemProps) => ( {label}
) +// #endregion -const SubscriptionBadge = ({ user }: { user: User }) => { +// #region Sub Badge +const SubscriptionBadge = ({ generations }: { generations: number }) => { return ( - - - - Free - - - -
-
- AI Generations - {`${user.generations} / ${MAX_FREE_GENERATION}`} +
+ + Free + + + + + + +
+
+ AI Generations + {`${generations} / ${MAX_FREE_GENERATION}`} +
+
- -
- - - + + + +
) } +// #endregion diff --git a/frontend/components/profile/navbar.tsx b/frontend/components/profile/navbar.tsx index e4e7d07..f7e8648 100644 --- a/frontend/components/profile/navbar.tsx +++ b/frontend/components/profile/navbar.tsx @@ -4,6 +4,7 @@ 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 ( @@ -19,7 +20,13 @@ export default function ProfileNavbar({ userData }: { userData: User }) {
- {Boolean(userData?.id) ? : null} + {Boolean(userData?.id) ? ( + + ) : ( + + + + )}
) diff --git a/frontend/lib/actions.ts b/frontend/lib/actions.ts index be1d357..5d63a28 100644 --- a/frontend/lib/actions.ts +++ b/frontend/lib/actions.ts @@ -1,6 +1,7 @@ "use server" import { revalidatePath } from "next/cache" +import { z } from "zod" export async function createSandbox(body: { type: string @@ -91,3 +92,95 @@ export async function unshareSandbox(sandboxId: string, userId: string) { 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" } + } +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 32f3246..9f588c1 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -23,7 +23,9 @@ export type Sandbox = { viewCount: number usersToSandboxes: UsersToSandboxes[] } - +export type SandboxWithLiked = Sandbox & { + liked: boolean +} export type UsersToSandboxes = { userId: string sandboxId: string From b0b34f29a998ca397b9b3cb6d36b8169b07ffc40 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 25 Nov 2024 21:54:43 +0100 Subject: [PATCH 7/8] feat: add tailwind intellisense to cva styles --- .vscode/settings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4b81cce..8771d74 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,9 @@ "editor.codeActionsOnSave": { "source.fixAll": "explicit", "source.organizeImports": "explicit" - } + }, + "tailwindCSS.experimental.classRegex": [ + ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], + ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + ] } From 46e3a3d871e8dfd84a41997f72b0ae7d853b3915 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Mon, 25 Nov 2024 21:55:08 +0100 Subject: [PATCH 8/8] feat: add active styles to buttons --- frontend/components/ui/button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: {