From 2ddddb1e6c6d19656a2e65453fef325157542703 Mon Sep 17 00:00:00 2001 From: Shun Usami Date: Sat, 28 Oct 2023 22:29:26 +0900 Subject: [PATCH] Playground/shadcn (#17) * [frontend] :recycle: Move Nav to RootLayout * [frontend] Add user list and signup page to Nav - [frontend] Applied Card component to signup page * [frontend] Fix value to defaultValue in user page * [frontend] Add UserCard component * [frontend] Update title and description * [frontend] Add toast on signup success/failure * [frontend] Redirect to user list page after signup * [frontend] Implement update and delete user * [frontend] `npx prettier --write frontend/src/**/*.tsx` * [frontend] No cache for user page, redirect to user list after delete * [backend] `npx prettier --write backend/src/**/*.ts` * [Makefile] Add fmt target --- Makefile | 4 + backend/src/user/user.controller.ts | 5 +- backend/src/user/user.service.ts | 18 +- frontend/package-lock.json | 58 ++++++ frontend/package.json | 1 + frontend/src/app/layout.tsx | 14 +- frontend/src/app/page.tsx | 6 +- frontend/src/app/user/[id]/page.tsx | 12 +- frontend/src/app/user/page.tsx | 46 +---- frontend/src/app/user/signup/page.tsx | 90 ++++++--- frontend/src/components/Nav.tsx | 11 +- frontend/src/components/UserCard.tsx | 120 ++++++++++++ frontend/src/components/ui/button.tsx | 26 +-- frontend/src/components/ui/card.tsx | 41 ++-- frontend/src/components/ui/dropdown-menu.tsx | 82 ++++---- frontend/src/components/ui/input.tsx | 16 +- frontend/src/components/ui/label.tsx | 20 +- frontend/src/components/ui/toast.tsx | 127 +++++++++++++ frontend/src/components/ui/toaster.tsx | 35 ++++ frontend/src/components/ui/use-toast.ts | 189 +++++++++++++++++++ frontend/src/lib/utils.ts | 8 +- 21 files changed, 749 insertions(+), 180 deletions(-) create mode 100644 frontend/src/components/UserCard.tsx create mode 100644 frontend/src/components/ui/toast.tsx create mode 100644 frontend/src/components/ui/toaster.tsx create mode 100644 frontend/src/components/ui/use-toast.ts diff --git a/Makefile b/Makefile index 3558eb0f..426d9e0e 100644 --- a/Makefile +++ b/Makefile @@ -42,3 +42,7 @@ dev: build .PHONY: prod prod: rebuild docker compose -f compose.yml -f compose.prod.yml up -d + +.PHONY: fmt +fmt: + npx prettier --write frontend/src backend/src diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 06c98dc6..f6735043 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -33,7 +33,10 @@ export class UserController { } @Patch(':id') - update(@Param('id') id: string, @Body() userData: { name?: string; email?: string }) { + update( + @Param('id') id: string, + @Body() userData: { name?: string; email?: string }, + ) { return this.userService.update(+id, userData); } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 6f92aecb..1ec223cb 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -12,23 +12,23 @@ export class UserService { } findAll() { - return this.prisma.user.findMany(); + return this.prisma.user.findMany(); } findOne(id: number) { - return this.prisma.user.findFirst( { where: { id: id } }); + return this.prisma.user.findFirst({ where: { id: id } }); } update(id: number, data: Prisma.UserUpdateInput) { - return this.prisma.user.update({ - data, - where: { id: id }, - }); + return this.prisma.user.update({ + data, + where: { id: id }, + }); } remove(id: number) { - return this.prisma.user.delete({ - where: { id: id }, - }); + return this.prisma.user.delete({ + where: { id: id }, + }); } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0da61b95..2a1127fc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "lucide-react": "^0.288.0", @@ -849,6 +850,40 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.5.tgz", + "integrity": "sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "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", @@ -955,6 +990,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", + "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7a3f29fd..fa7f32ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "lucide-react": "^0.288.0", diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 96bccde0..722fac2c 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,13 +1,17 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; + +// components import { ThemeProvider } from "@/components/theme-provider"; +import Nav from "@/components/Nav"; +import { Toaster } from "@/components/ui/toaster"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Pong", + description: "Classic Pong game", }; export default function RootLayout({ @@ -24,7 +28,11 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - {children} +
+
+ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3c82182f..23cb3911 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,15 +1,13 @@ -import Nav from "../components/Nav"; import { Button } from "@/components/ui/button"; export default function Home() { return ( -
-
+ ); } diff --git a/frontend/src/app/user/[id]/page.tsx b/frontend/src/app/user/[id]/page.tsx index c653cf2d..55e400d2 100644 --- a/frontend/src/app/user/[id]/page.tsx +++ b/frontend/src/app/user/[id]/page.tsx @@ -1,5 +1,9 @@ +import UserCard from "@/components/UserCard"; + async function getUser(id: number) { - const res = await fetch(`${process.env.API_URL}/user/${id}`); + const res = await fetch(`${process.env.API_URL}/user/${id}`, { + cache: "no-cache", + }); const user = await res.json(); return user; } @@ -10,9 +14,5 @@ export default async function FindUser({ params: { id: number }; }) { const user = await getUser(id); - return ( -
- {user.name}({user.id}) : {user.email} -
- ); + return ; } diff --git a/frontend/src/app/user/page.tsx b/frontend/src/app/user/page.tsx index 99c7cd0e..89fe114f 100644 --- a/frontend/src/app/user/page.tsx +++ b/frontend/src/app/user/page.tsx @@ -1,15 +1,4 @@ -import Nav from "@/components/Nav"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; +import UserCard from "@/components/UserCard"; export type User = { id: number; name?: string; email?: string }; @@ -24,33 +13,10 @@ async function getUsers(): Promise { export default async function UserListPage() { const users = await getUsers(); return ( -
-
+
+ {users.map((user, index) => ( + + ))} +
); } diff --git a/frontend/src/app/user/signup/page.tsx b/frontend/src/app/user/signup/page.tsx index 4e90f792..ac1d5b7c 100644 --- a/frontend/src/app/user/signup/page.tsx +++ b/frontend/src/app/user/signup/page.tsx @@ -1,30 +1,74 @@ "use client"; -async function submit(event: React.FormEvent) { - event.preventDefault(); - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/user`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(Object.fromEntries(new FormData(event.currentTarget))), - }); - const user = await res.json(); - console.log(user); -} +import { useRouter } from "next/navigation"; + +// components +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/use-toast"; export default function SignUp() { + const router = useRouter(); + const { toast } = useToast(); + async function createUser(event: React.FormEvent) { + event.preventDefault(); + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/user`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify( + Object.fromEntries(new FormData(event.currentTarget)), + ), + }); + const data = await res.json(); + if (!res.ok) { + toast({ + title: res.status + " " + res.statusText, + description: data.message, + }); + } else { + toast({ + title: "Success", + description: "User created successfully.", + }); + router.push("/user"); + router.refresh(); + } + } + return ( -
- - - -
+ + Create Account + +
+
+
+ + +
+
+ + +
+ +
+
+
+
); } diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index 68486530..629a3f80 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -1,5 +1,6 @@ import Image from "next/image"; import { ModeToggle } from "./toggle-mode"; +import Link from "next/link"; export default function Nav() { return ( @@ -16,7 +17,15 @@ export default function Nav() { priority /> -
  • +
  • + + Home + + User List + Sign Up + + Pong +
  • diff --git a/frontend/src/components/UserCard.tsx b/frontend/src/components/UserCard.tsx new file mode 100644 index 00000000..ad7761f3 --- /dev/null +++ b/frontend/src/components/UserCard.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +// components +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/use-toast"; + +export type User = { id: number; name?: string; email?: string }; + +export default function UserCard({ user }: { user: User }) { + const router = useRouter(); + const { toast } = useToast(); + async function updateUser(event: React.FormEvent) { + event.preventDefault(); + const { id, ...updateData } = Object.fromEntries( + new FormData(event.currentTarget), + ); + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/user/${user.id}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(updateData), + }, + ); + const data = await res.json(); + if (!res.ok) { + toast({ + title: res.status + " " + res.statusText, + description: data.message, + }); + } else { + toast({ + title: "Success", + description: "User updated successfully.", + }); + router.push("/user"); + router.refresh(); + } + } + async function deleteUser(event: React.FormEvent) { + event.preventDefault(); + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/user/${user.id}`, + { + method: "DELETE", + }, + ); + const data = await res.json(); + if (!res.ok) { + toast({ + title: res.status + " " + res.statusText, + description: data.message, + }); + } else { + toast({ + title: "Success", + description: "User deleted successfully.", + }); + router.push("/user"); + router.refresh(); + } + } + return ( + <> + + ID: {user.id} + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + + + +
    + + ); +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 0ba42773..57c9fe47 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", @@ -30,27 +30,27 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } -) + }, +); export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { - asChild?: boolean + asChild?: boolean; } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; return ( - ) - } -) -Button.displayName = "Button" + ); + }, +); +Button.displayName = "Button"; -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index afa13ecf..dc3b01de 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Card = React.forwardRef< HTMLDivElement, @@ -10,12 +10,12 @@ const Card = React.forwardRef< ref={ref} className={cn( "rounded-lg border bg-card text-card-foreground shadow-sm", - className + className, )} {...props} /> -)) -Card.displayName = "Card" +)); +Card.displayName = "Card"; const CardHeader = React.forwardRef< HTMLDivElement, @@ -26,8 +26,8 @@ const CardHeader = React.forwardRef< className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} /> -)) -CardHeader.displayName = "CardHeader" +)); +CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef< HTMLParagraphElement, @@ -37,12 +37,12 @@ const CardTitle = React.forwardRef< ref={ref} className={cn( "text-2xl font-semibold leading-none tracking-tight", - className + className, )} {...props} /> -)) -CardTitle.displayName = "CardTitle" +)); +CardTitle.displayName = "CardTitle"; const CardDescription = React.forwardRef< HTMLParagraphElement, @@ -53,16 +53,16 @@ const CardDescription = React.forwardRef< className={cn("text-sm text-muted-foreground", className)} {...props} /> -)) -CardDescription.displayName = "CardDescription" +)); +CardDescription.displayName = "CardDescription"; const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
    -)) -CardContent.displayName = "CardContent" +)); +CardContent.displayName = "CardContent"; const CardFooter = React.forwardRef< HTMLDivElement, @@ -73,7 +73,14 @@ const CardFooter = React.forwardRef< className={cn("flex items-center p-6 pt-0", className)} {...props} /> -)) -CardFooter.displayName = "CardFooter" +)); +CardFooter.displayName = "CardFooter"; -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx index f69a0d64..3a0c7fed 100644 --- a/frontend/src/components/ui/dropdown-menu.tsx +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -1,27 +1,27 @@ -"use client" +"use client"; -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { Check, ChevronRight, Circle } from "lucide-react" +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const DropdownMenu = DropdownMenuPrimitive.Root +const DropdownMenu = DropdownMenuPrimitive.Root; -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -const DropdownMenuGroup = DropdownMenuPrimitive.Group +const DropdownMenuGroup = DropdownMenuPrimitive.Group; -const DropdownMenuPortal = DropdownMenuPrimitive.Portal +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub +const DropdownMenuSub = DropdownMenuPrimitive.Sub; -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, children, ...props }, ref) => ( {children} -)) +)); DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName + DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< React.ElementRef, @@ -48,13 +48,13 @@ const DropdownMenuSubContent = React.forwardRef< ref={ref} className={cn( "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className + className, )} {...props} /> -)) +)); DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName + DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< React.ElementRef, @@ -66,18 +66,18 @@ const DropdownMenuContent = React.forwardRef< sideOffset={sideOffset} className={cn( "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className + className, )} {...props} /> -)) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, @@ -100,7 +100,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< ref={ref} className={cn( "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className + className, )} checked={checked} {...props} @@ -112,9 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef< {children} -)) +)); DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName + DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, @@ -124,7 +124,7 @@ const DropdownMenuRadioItem = React.forwardRef< ref={ref} className={cn( "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className + className, )} {...props} > @@ -135,13 +135,13 @@ const DropdownMenuRadioItem = React.forwardRef< {children} -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< React.ElementRef, @@ -165,8 +165,8 @@ const DropdownMenuSeparator = React.forwardRef< className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} /> -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, @@ -177,9 +177,9 @@ const DropdownMenuShortcut = ({ className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} /> - ) -} -DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; export { DropdownMenu, @@ -197,4 +197,4 @@ export { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, -} +}; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 677d05fd..9d631e7f 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; export interface InputProps extends React.InputHTMLAttributes {} @@ -12,14 +12,14 @@ const Input = React.forwardRef( type={type} className={cn( "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", - className + className, )} ref={ref} {...props} /> - ) - } -) -Input.displayName = "Input" + ); + }, +); +Input.displayName = "Input"; -export { Input } +export { Input }; diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx index 53418217..84f8b0c7 100644 --- a/frontend/src/components/ui/label.tsx +++ b/frontend/src/components/ui/label.tsx @@ -1,14 +1,14 @@ -"use client" +"use client"; -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const labelVariants = cva( - "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" -) + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); const Label = React.forwardRef< React.ElementRef, @@ -20,7 +20,7 @@ const Label = React.forwardRef< className={cn(labelVariants(), className)} {...props} /> -)) -Label.displayName = LabelPrimitive.Root.displayName +)); +Label.displayName = LabelPrimitive.Root.displayName; -export { Label } +export { Label }; diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx new file mode 100644 index 00000000..2bc23c1f --- /dev/null +++ b/frontend/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +type ToastProps = React.ComponentPropsWithoutRef; + +type ToastActionElement = React.ReactElement; + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +}; diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx new file mode 100644 index 00000000..7d82ed55 --- /dev/null +++ b/frontend/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast"; +import { useToast } from "@/components/ui/use-toast"; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
    + {title && {title}} + {description && ( + {description} + )} +
    + {action} + +
    + ); + })} + +
    + ); +} diff --git a/frontend/src/components/ui/use-toast.ts b/frontend/src/components/ui/use-toast.ts new file mode 100644 index 00000000..ac0aff3e --- /dev/null +++ b/frontend/src/components/ui/use-toast.ts @@ -0,0 +1,189 @@ +// Inspired by react-hot-toast library +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; + +type ToasterToast = ToastProps & { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_VALUE; + return count.toString(); +} + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t, + ), + }; + + case "DISMISS_TOAST": { + const { toastId } = action; + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t, + ), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +type Toast = Omit; + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { useToast, toast }; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index ec79801f..365058ce 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { type ClassValue, clsx } from "clsx" -import { twMerge } from "tailwind-merge" - +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); }