diff --git a/app/(content)/(gaming)/games/page.tsx b/app/(content)/(gaming)/games/page.tsx index 4ff601ba9e793611a00380291f9f29d1510f6ae6..81b91728af8c7021d3f6b122f32c00ba5d3b0d9f 100644 --- a/app/(content)/(gaming)/games/page.tsx +++ b/app/(content)/(gaming)/games/page.tsx @@ -1,7 +1,6 @@ import Sort from "@/components/filter-sort-games"; import { InfiniteScrollGames } from "@/components/infinity-scroll"; import ScrollToTop from "@/components/scroll-to-top"; -import SearchInput from "@/components/search-input"; // renders a list of games infinitely export default async function GamesPage() { @@ -9,9 +8,6 @@ export default async function GamesPage() { <> <main className="relative lg:gap-10 xl:grid xl:grid-cols-[1fr_240px]"> <div className="grid"> - <div className="flex flex-col gap-10 items-center w-full"> - <SearchInput className="p-3 lg:w-2/3 2xl:w-1/3" /> - </div> <InfiniteScrollGames /> </div> <div className="hidden xl:block flex-col md:flex"> diff --git a/app/(content)/followers/page.tsx b/app/(content)/(user)/followers/page.tsx similarity index 100% rename from app/(content)/followers/page.tsx rename to app/(content)/(user)/followers/page.tsx diff --git a/app/(content)/notifications/page.tsx b/app/(content)/(user)/notifications/page.tsx similarity index 100% rename from app/(content)/notifications/page.tsx rename to app/(content)/(user)/notifications/page.tsx diff --git a/app/(content)/layout.tsx b/app/(content)/layout.tsx index 2d8363e749fec02a8da7afda52029ca0c4abcbad..3c53c0a1715f24a9235da32dc946be7385c556a8 100644 --- a/app/(content)/layout.tsx +++ b/app/(content)/layout.tsx @@ -1,6 +1,10 @@ +import { MainNav } from "@/components/header" import DashboardNav from "@/components/nav" +import SearchInput from "@/components/search-input" import { SiteFooter } from "@/components/site-footer" +import { UserAccountNav } from "@/components/user-nav" import { dashboardConfig } from "@/lib/config/dashboard" +import { getCurrentUser } from "@/lib/session" interface DashboardLayoutProps { children?: React.ReactNode @@ -8,13 +12,27 @@ interface DashboardLayoutProps { export default async function ContentLayout({ children, }: DashboardLayoutProps) { + const user = await getCurrentUser() + return ( - <div className="flex flex-col min-h-screen"> - <div className="mx-32 my-6 flex-1 md:grid md:grid-cols-[220px_1fr] md:gap-6 lg:grid-cols-[240px_1fr] lg:gap-10"> + <div className="flex min-h-screen flex-col space-y-6"> + <header className="sticky top-0 z-40 border-b bg-background"> + <div className="container flex h-16 items-center justify-between py-4"> + <MainNav /> + <SearchInput className="p-3 md:w-2/3 2xl:w-1/3" /> + {user && <UserAccountNav + user={{ + name: user?.name, + image: user?.image, + username: user?.username, + }} + />} + {!user && <p className="w-8"></p>} + </div> + </header> + <div className="container grid flex-1 gap-12 md:grid-cols-[200px_1fr]"> <aside className="hidden w-[200px] flex-col md:flex"> - <div className="sticky top-0"> - <DashboardNav items={dashboardConfig.sidebarNav} /> - </div> + <DashboardNav items={dashboardConfig.sidebarNav} /> </aside> <main className="flex w-full flex-1 flex-col overflow-hidden"> {children} diff --git a/app/api/signup/route.ts b/app/api/signup/route.ts index 124f2fdc5cef381eafdba1e9e08508adc245c308..6d2b390be2f6b401dea0ac7c164ca8b84e2e75ab 100644 --- a/app/api/signup/route.ts +++ b/app/api/signup/route.ts @@ -1,13 +1,15 @@ import { db } from '@/lib/db' import { hash } from 'bcrypt' import { NextResponse } from 'next/server' +import { normalize } from 'normalize-diacritics' export async function POST(req: Request) { try { const { username, email, password } = await req.json() const hashed = await hash(password, 12) - let usernameCheck = username.toLowerCase() + const normalizedName = await normalize(username.toLowerCase()); + let usernameCheck = normalizedName const emailCheck = email.toLowerCase() const existingUser = await db.user.findUnique({ @@ -29,7 +31,7 @@ export async function POST(req: Request) { }) if (existingUserName) { - usernameCheck = `${username}${Math.floor(Math.random() * 1000)}` + usernameCheck = `${normalizedName}${Math.floor(Math.random() * 1000)}` } else { isUnique = true; } diff --git a/app/globals.css b/app/globals.css index 6ae9916a41b6669bafed4c6a755627c752e8b197..cb7efa4d9556ea875c5bced31c08b5d8a75a3a6e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,38 +4,52 @@ @layer base { :root { + /* Default background color of <body />...etc */ --background: 0 0% 100%; --foreground: 222.2 47.4% 11.2%; + /* Muted backgrounds such as <TabsList />, <Skeleton /> and <Switch /> */ --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; + /* Background color for popovers such as <DropdownMenu />, <HoverCard />, <Popover /> */ --popover: 0 0% 100%; --popover-foreground: 222.2 47.4% 11.2%; + /* Background color for <Card /> */ --card: 0 0% 100%; --card-foreground: 222.2 47.4% 11.2%; + /* Default border color */ --border: 214.3 31.8% 91.4%; + + /* Border color for inputs such as <Input />, <Select />, <Textarea /> */ --input: 214.3 31.8% 91.4%; - --primary: 222.2 47.4% 11.2%; + /* Primary colors for <Button /> */ + --primary: 255 83% 65%; --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; + /* Secondary colors for <Button /> */ + --secondary: 237 100% 96%; --secondary-foreground: 222.2 47.4% 11.2%; + /* Used for accents such as hover effects on <DropdownMenuItem>, <SelectItem>...etc */ --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; + /* Used for destructive actions such as <Button variant="destructive"> */ --destructive: 0 100% 50%; --destructive-foreground: 210 40% 98%; + /* Used for focus ring */ --ring: 215 20.2% 65.1%; + /* Border radius for card, input and buttons */ --radius: 0.5rem; } + /* Same for Dark-Mode */ .dark { --background: 224 71% 4%; --foreground: 213 31% 91%; diff --git a/components/filter-sort-games.tsx b/components/filter-sort-games.tsx index caa9c363c3aeea948dca644e11642d38da75fc43..9b37f76745994a1456f4e59862a3e389b18124e5 100644 --- a/components/filter-sort-games.tsx +++ b/components/filter-sort-games.tsx @@ -57,10 +57,10 @@ export default function Sort() { } return ( - <Card className="p-6 grid items-start gap-2"> + <Card className="p-6 grid items-start gap-2 bg-secondary"> <h1>Filter</h1> <Select value={selectedCategory ? selectedCategory : undefined} key={selectedCategory[0]} onValueChange={(value) => setSelectedCategory(value)}> - <SelectTrigger className={`w-full ${selectedCategory[0] ? 'font-extrabold' : ''}`}> + <SelectTrigger className={`bg-background border-none w-full ${selectedCategory[0] ? 'font-extrabold' : ''}`}> <SelectValue placeholder="By category..." /> </SelectTrigger> <SelectContent> @@ -77,7 +77,7 @@ export default function Sort() { </Select> <Select value={selectedGenre ? selectedGenre : undefined} key={selectedGenre[0]} onValueChange={(value) => setSelectedGenre(value)}> - <SelectTrigger className={`w-full ${selectedGenre[0] ? 'font-extrabold' : ''}`}> + <SelectTrigger className={`bg-background border-none w-full ${selectedGenre[0] ? 'font-extrabold' : ''}`}> <SelectValue placeholder="By genre..." /> </SelectTrigger> <SelectContent> @@ -109,7 +109,7 @@ export default function Sort() { </Select> <Select value={selectedPlatform ? selectedPlatform : undefined} key={selectedPlatform[0]} onValueChange={(value) => setSelectedPlatform(value)}> - <SelectTrigger className={`w-full ${selectedPlatform[0] ? 'font-extrabold' : ''}`}> + <SelectTrigger className={`bg-background border-none w-full ${selectedPlatform[0] ? 'font-extrabold' : ''}`}> <SelectValue placeholder="By Platform..." /> </SelectTrigger> <SelectContent> @@ -126,7 +126,7 @@ export default function Sort() { <h1 className="pt-6">Sort by</h1> <div className="flex space-x-2 pb-1"> <Select value={selectedSortMethod} onValueChange={(value) => setSelectedSortMethod(value)}> - <SelectTrigger className="w-full"> + <SelectTrigger className="bg-background border-none w-full"> <SelectValue placeholder="Rating" /> </SelectTrigger> <SelectContent> @@ -137,7 +137,7 @@ export default function Sort() { </SelectGroup> </SelectContent> </Select> - <Button variant="ghost" onClick={() => toggleSortOrder()}> + <Button variant="ghost" onClick={() => toggleSortOrder()} className="bg-background border-none"> <Icons.arrowdown className={`h-4 w-4 transition-all transform ${selectedSortOrder === 'asc' ? 'rotate-180' : ''}`} /> </Button> </div> diff --git a/components/header.tsx b/components/header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cdf1c8c5ad8f903e57ed434e5d933c18b21b2512 --- /dev/null +++ b/components/header.tsx @@ -0,0 +1,15 @@ +"use client" + +import Link from "next/link" + +import { GameUnityLogo } from "./logo" + +export function MainNav() { + return ( + <div className="flex gap-6 md:gap-10"> + <Link href="/" className="items-center space-x-2 flex"> + <GameUnityLogo className="h-8 w-8" /> + </Link> + </div> + ) +} \ No newline at end of file diff --git a/components/icons.tsx b/components/icons.tsx index 6704d7828d7dae71b71e4b123ab953bd71450269..cdacdf511e61cfebad9cebf775031ff81d2908ab 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -41,18 +41,18 @@ export type Icon = LucideIcon export const Icons: IconsType = { logo: ({ ...props }: LucideProps) => ( - <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> - <path d="M13.7067 17.1466V22C13.7067 23.1046 12.8113 24 11.7067 24H8.85339C7.74882 24 6.85339 23.1046 6.85339 22V17.1466L10.3417 13.6667L13.7067 17.1466Z" fill="#16161A" /> - <path d="M12.2799 0H15.1332C16.2378 0 17.1332 0.895431 17.1332 2V6.85339L13.6776 10.3333L10.2799 6.85339V2C10.2799 0.895429 11.1753 0 12.2799 0Z" fill="#16161A" /> - <path d="M2 6.86666H6.85332L10.3416 10.3333L6.85332 13.72H2C0.895432 13.72 0 12.8246 0 11.72V8.86666C0 7.76209 0.895431 6.86666 2 6.86666Z" fill="#7F5AF0" /> - <path d="M17.1466 10.2934H21.9999C23.1045 10.2934 23.9999 11.1888 23.9999 12.2934V15.1466C23.9999 16.2512 23.1045 17.1466 21.9999 17.1466H17.1466L13.6776 13.6667L17.1466 10.2934Z" fill="#16161A" /> + <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> + <path d="M20.5603 25.7199V34C20.5603 35.1046 19.6649 36 18.5603 36H12.2803C11.1757 36 10.2803 35.1046 10.2803 34V25.7199L15.5127 20.5L20.5603 25.7199Z" fill="#16161A" /> + <path d="M17.4199 0H23.6999C24.8045 0 25.6999 0.89543 25.6999 2V10.2801L20.5164 15.5L15.4199 10.2801V2C15.4199 0.895429 16.3154 0 17.4199 0Z" fill="#16161A" /> + <path d="M2 10.2988H10.28L15.5124 15.4988L10.28 20.5789H2C0.895428 20.5789 0 19.6835 0 18.5789V12.2988C0 11.1943 0.895431 10.2988 2 10.2988Z" fill="#7F5AF0" /> + <path d="M25.7201 15.4395H34.0001C35.1047 15.4395 36.0001 16.3349 36.0001 17.4395V23.7193C36.0001 24.8239 35.1047 25.7193 34.0001 25.7193H25.7201L20.5166 20.4994L25.7201 15.4395Z" fill="#16161A" /> </svg>), logoWhite: ({ ...props }: LucideProps) => ( - <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> - <path d="M13.7067 17.1466V22C13.7067 23.1046 12.8113 24 11.7067 24H8.85339C7.74882 24 6.85339 23.1046 6.85339 22V17.1466L10.3417 13.6667L13.7067 17.1466Z" fill="#FFFFFE" /> - <path d="M12.2799 0H15.1332C16.2378 0 17.1332 0.895431 17.1332 2V6.85339L13.6776 10.3333L10.2799 6.85339V2C10.2799 0.895429 11.1753 0 12.2799 0Z" fill="#FFFFFE" /> - <path d="M2 6.86666H6.85332L10.3416 10.3333L6.85332 13.72H2C0.895432 13.72 0 12.8246 0 11.72V8.86666C0 7.76209 0.895431 6.86666 2 6.86666Z" fill="#FFFFFE" /> - <path d="M17.1466 10.2934H21.9999C23.1045 10.2934 23.9999 11.1888 23.9999 12.2934V15.1466C23.9999 16.2512 23.1045 17.1466 21.9999 17.1466H17.1466L13.6776 13.6667L17.1466 10.2934Z" fill="#FFFFFE" /> + <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> + <path d="M20.5602 25.7198V33.9998C20.5602 35.1044 19.6647 35.9998 18.5602 35.9998H12.2803C11.1757 35.9998 10.2803 35.1044 10.2803 33.9998V25.7198L15.5127 20.5L20.5602 25.7198Z" fill="#FFFFFE" /> + <path d="M17.4199 0H23.6998C24.8043 0 25.6998 0.895431 25.6998 2V10.2799L20.5164 15.4998L15.4199 10.2799V2C15.4199 0.895433 16.3154 0 17.4199 0Z" fill="#FFFFFE" /> + <path d="M2 10.2998H10.2799L15.5122 15.4997L10.2799 20.5798H2C0.895432 20.5798 0 19.6843 0 18.5798V12.2998C0 11.1952 0.895431 10.2998 2 10.2998Z" fill="#7F5AF0" /> + <path d="M25.72 15.4385H33.9999C35.1045 15.4385 35.9999 16.3339 35.9999 17.4385V23.7182C35.9999 24.8228 35.1045 25.7182 33.9999 25.7182H25.72L20.5166 20.4983L25.72 15.4385Z" fill="#FFFFFE" /> </svg>), logoName: ({ ...props }: LucideProps) => ( <svg width="114" height="22" viewBox="0 0 114 22" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> diff --git a/components/mode-toggle.tsx b/components/mode-toggle.tsx index 2220f939f45e7c972253aeb13122378ff4a8b56b..aa1e5827a5b28455b6b8963e73bfe6f5d64d646b 100644 --- a/components/mode-toggle.tsx +++ b/components/mode-toggle.tsx @@ -8,6 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" import { useTheme } from "next-themes" export function ModeToggle() { @@ -16,13 +17,16 @@ export function ModeToggle() { return ( <DropdownMenu> <DropdownMenuTrigger asChild> - <Button variant="ghost" size="sm" className="h-8 w-8 px-0"> - <Icons.sun className="rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> - <Icons.moon className="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> + <span className={cn( + "group flex items-center rounded-md px-3 py-2 font-medium hover:bg-accent hover:text-accent-foreground cursor-pointer", + )}> + <Icons.sun className="mr-3 h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> + <Icons.moon className="mr-3 h-5 w-5 absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <span className="sr-only">Toggle theme</span> - </Button> + <span>Mode</span> + </span> </DropdownMenuTrigger> - <DropdownMenuContent align="end"> + <DropdownMenuContent> <DropdownMenuItem onClick={() => setTheme("light")}> <Icons.sun className="mr-2 h-4 w-4" /> <span>Light</span> diff --git a/components/nav.tsx b/components/nav.tsx index e4743b8ac8d30f77489c7f54507d8a9ca8f7f339..a6d6e247390b8102193323cb26041b491133fdfc 100644 --- a/components/nav.tsx +++ b/components/nav.tsx @@ -4,74 +4,64 @@ import { Icons, IconsType } from "@/components/icons"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { SidebarNavItem } from "@/types"; -import { signIn, signOut, useSession } from "next-auth/react"; +import { useSession } from "next-auth/react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { GameUnityLogo } from "./logo"; import { ModeToggle } from "./mode-toggle"; interface DashboardNavProps { - items: SidebarNavItem[] + items: SidebarNavItem[]; } export default function DashboardNav({ items }: DashboardNavProps) { - const path = usePathname() + const path = usePathname(); const { data: session } = useSession(); + if (!items?.length) { - return null + return null; } - const isLoaded = true - const user = "test" + const visibleItems = session ? items : items.slice(0, 2); return ( <nav className="grid items-start gap-2"> - <div className="flex items-center"> - <Link href="/" className={cn("rounded-full p-3 hover:bg-accent")}> - <GameUnityLogo className="h-8 w-8" /> - </Link> - </div> - {session?.user && isLoaded && user ? - (items.map((item, index) => { - const Icon = Icons[item.icon as keyof IconsType || "arrowRight"]; - if (item.title === "My Profile") { - item.href = `/${user}` - } - return ( - item.href && ( - <Link key={index} href={item.disabled ? "/" : item.href} className={index == 6 ? "mt-10" : ""}> - <span - className={cn( - "group flex items-center rounded-md px-3 py-2 font-medium hover:bg-accent hover:text-accent-foreground", - path === item.href ? "bg-accent" : "transparent", - item.disabled && "cursor-not-allowed opacity-80" - )} - > - <Icon className="mr-2 h-4 w-4" /> - <span>{item.title}</span> - </span> - </Link> - ) + {visibleItems.map((item, index) => { + const Icon = Icons[item.icon as keyof IconsType || "arrowRight"]; + if (item.title === "My Profile") { + item.href = `/${session?.user?.username}` + } + return ( + item.href && ( + <Link key={index} href={item.disabled ? "/" : item.href} className={item.title === "Settings" ? "mt-10" : ""}> + <span + className={cn( + "group flex items-center rounded-md px-3 py-2 font-medium hover:bg-accent hover:text-accent-foreground", + path === item.href ? "bg-accent" : "transparent", + item.disabled && "cursor-not-allowed opacity-80" + )} + > + <Icon className="mr-3 h-5 w-5" /> + <span>{item.title}</span> + </span> + </Link> ) - })) - : - <div className="space-x-2 space-y-2 justify-center text-center"> - <Link href="/login" className={cn(buttonVariants({ size: "lg" }))}>Log In</Link> - <Link href="/signup" className={cn(buttonVariants({ size: "lg", variant: "outline" }))}>Sign Up</Link> - <p> + ) + + })} + <ModeToggle /> + {!session && ( + <div className="mt-24 space-y-3 justify-center text-center"> + <Link href="/login" className={cn(buttonVariants({ size: "lg" }), "w-full")}> + Log In + </Link> + <Link href="/signup" className={cn(buttonVariants({ size: "lg", variant: "outline" }), "w-full")}> + Sign Up + </Link> + <h4 className="text-sm"> Unlock endless possibilities - sign up or log in to unleash the full potential of our website. - </p> + </h4> </div> - } - {session?.user && - <> - <p className="text-sky-600"> {session?.user.name}</p> - <button className=" text-red-500" onClick={() => signOut()}> - Sign Out - </button> - </> - } - <ModeToggle /> + )} </nav> - ) + ); } \ No newline at end of file diff --git a/components/search-input.tsx b/components/search-input.tsx index 940f0e5beaff744085aa1e60cb6cef17f9e2fa7e..1786c3c50c8ef3dbc2da18cb3ce48cf1913789b3 100644 --- a/components/search-input.tsx +++ b/components/search-input.tsx @@ -6,6 +6,7 @@ import { useState } from 'react'; import { Icons } from './icons'; import { Button } from './ui/button'; import { Input } from './ui/input'; +import { toast } from './ui/use-toast'; interface DocsSearchProps extends React.HTMLAttributes<HTMLFormElement> { } @@ -17,13 +18,20 @@ export default function SearchInput({ className, ...props }: DocsSearchProps) { function onSearch(event: React.FormEvent) { event.preventDefault() - if (!searchQuery) { - router.push(pathname) - return - } + if (pathname === "/games") { + if (!searchQuery) { + router.push(pathname) + return + } - const encoededQuery = encodeURIComponent(searchQuery) - router.push(`${pathname}?search=${encoededQuery}`) // add useSearchParams + const encoededQuery = encodeURIComponent(searchQuery) + router.push(`${pathname}?search=${encoededQuery}`) + } else { + return toast({ + title: "Work in Progress!", + description: "Sorry, but global search is not available yet... ã…¤You can test it out in the games page though!", + }) + } }; return ( @@ -39,7 +47,7 @@ export default function SearchInput({ className, ...props }: DocsSearchProps) { value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} /> - <Button variant="outline" size="lg" className="absolute align-middle h-8 px-2.5 mr-1"> + <Button size="lg" className="absolute align-middle h-8 px-2.5 mr-1"> <Icons.arrowRight className="h-3 w-3" /> </Button> </form> diff --git a/components/site-footer.tsx b/components/site-footer.tsx index 2a0508e86eb8b2794275491f1e549aeb7e1fb953..d1d87ab569b91d4e91d015007a0ae8d650cf38b4 100644 --- a/components/site-footer.tsx +++ b/components/site-footer.tsx @@ -4,26 +4,24 @@ import { cn } from "@/lib/utils" export function SiteFooter({ className }: React.HTMLAttributes<HTMLElement>) { return ( <footer className={cn(className)}> - <div className="container flex flex-col items-center justify-between gap-4 py-10 md:h-24 md:flex-row md:py-0"> - <div className="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0"> - <span> - <Icons.logo className="h-7 w-7 dark:hidden" /> - </span> - <Icons.logoWhite className="h-7 w-7 hidden dark:block" /> - <span className="px-3"> - <Icons.logoName className="dark:hidden" /> - <Icons.logoWhiteName className="hidden dark:block" /> - </span> - <p className="text-center text-sm leading-loose md:text-left"> - Built by - Yusuf Akgül, - Omar Kasbah, - Caner Ilaslan, - David Jakszta, - Serdar Dorak, - and Valeria Luft - with â¤ï¸ - </p> + <div className="bg-black"> + <div className="container flex flex-col items-center justify-between gap-4 py-10 md:h-24 md:flex-row md:py-0"> + <div className="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0"> + <Icons.logoWhite className="h-7 w-7" /> + <span className="pr-6"> + <Icons.logoWhiteName className="" /> + </span> + <p className="text-white text-center text-sm leading-loose md:text-left"> + Built by + Yusuf Akgül, + Omar Kasbah, + Caner Ilaslan, + David Jakszta, + Serdar Dorak, + and Valeria Luft + with â¤ï¸ + </p> + </div> </div> </div> </footer> diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..51e507ba9d08bcdbb1fb630498f1cbdf2bf50093 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Root + ref={ref} + className={cn( + "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", + className + )} + {...props} + /> +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Image>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Image + ref={ref} + className={cn("aspect-square h-full w-full", className)} + {...props} + /> +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Fallback>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Fallback + ref={ref} + className={cn( + "flex h-full w-full items-center justify-center rounded-full bg-muted", + className + )} + {...props} + /> +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 399647c410565e8cbae97a3bb1eb59d82c1d1bd4..7419a7f5404578220055d1aaa678470e77c72349 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -13,7 +13,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: - "border border-input hover:bg-accent hover:text-accent-foreground", + "text-primary border-primary border-2 input hover:bg-accent", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", @@ -53,3 +53,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( Button.displayName = "Button" export { Button, buttonVariants } + diff --git a/components/user-auth-form.tsx b/components/user-auth-form.tsx index f4f207283ae53a0046ba00da9b57b1cfdc118c52..cb42f0702876a2360de7d2d7b3f531192f70fd4b 100644 --- a/components/user-auth-form.tsx +++ b/components/user-auth-form.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { signIn } from 'next-auth/react' -import { useSearchParams } from "next/navigation" +import { useRouter, useSearchParams } from "next/navigation" import { HTMLAttributes, useState } from 'react' import { useForm } from 'react-hook-form' import * as z from "zod" @@ -33,6 +33,7 @@ export function UserAuthForm({ type, className, ...props }: UserAuthFormProps) { }) const [isLoading, setIsLoading] = useState<boolean>(false) const [isGitHubLoading, setIsGitHubLoading] = useState<boolean>(false) + const router = useRouter(); const searchParams = useSearchParams() async function onSubmit(data: FormData) { @@ -68,13 +69,25 @@ export function UserAuthForm({ type, className, ...props }: UserAuthFormProps) { const signInResult = await signIn("credentials", { usernameOrEmail: data.email?.toLowerCase() || data.usernameOrEmail?.toLowerCase(), password: data.password, - redirect: true, + redirect: false, callbackUrl: searchParams?.get("from") || "/home", }); setIsLoading(false) if (signInResult?.error) { + if (signInResult.error === "user not found") { + setError('usernameOrEmail', { + type: 'manual', + message: 'Sorry, we couldn\'t find an account with the provided email / username. Please double-check your input or create a new account.' + }); + } + if (signInResult.error === "invalid password") { + setError('password', { + type: 'manual', + message: 'Sorry, but it seems like the password you entered is invalid. Please try again.' + }); + } return toast({ variant: "destructive", title: "Uh oh! Something went wrong.", @@ -83,6 +96,8 @@ export function UserAuthForm({ type, className, ...props }: UserAuthFormProps) { }) } + router.push("/home") + if (type === "signup") { return toast({ title: "Congratulations!", @@ -90,7 +105,7 @@ export function UserAuthForm({ type, className, ...props }: UserAuthFormProps) { }) } else { return toast({ - title: "Logging in.", + title: "Login successful.", description: "You will be redirected shortly.", }) } diff --git a/components/user-avatar.tsx b/components/user-avatar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c627d497b5e18a469c8478332ccc1ddf3d7e0f81 --- /dev/null +++ b/components/user-avatar.tsx @@ -0,0 +1,24 @@ +import { User } from "@prisma/client" +import { AvatarProps } from "@radix-ui/react-avatar" + +import { Icons } from "@/components/icons" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" + +interface UserAvatarProps extends AvatarProps { + user: Pick<User, "image" | "name"> +} + +export function UserAvatar({ user, ...props }: UserAvatarProps) { + return ( + <Avatar {...props}> + {user.image ? ( + <AvatarImage alt="Picture" src={user.image} /> + ) : ( + <AvatarFallback> + <span className="sr-only">{user.name}</span> + <Icons.user className="h-4 w-4" /> + </AvatarFallback> + )} + </Avatar> + ) +} \ No newline at end of file diff --git a/components/user-nav.tsx b/components/user-nav.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a6e1c0aadf35e6ae863669563cc8180847568336 --- /dev/null +++ b/components/user-nav.tsx @@ -0,0 +1,56 @@ +"use client" + +import { User } from "next-auth" +import { signOut } from "next-auth/react" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { UserAvatar } from "@/components/user-avatar" + +type UserUsername = string | null | undefined + +interface UserAccountNavProps extends React.HTMLAttributes<HTMLDivElement> { + user: Pick<User & { username: UserUsername }, "name" | "image" | "username"> +} + +export function UserAccountNav({ user }: UserAccountNavProps) { + return ( + <DropdownMenu> + <DropdownMenuTrigger className="rounded-full"> + <UserAvatar + user={{ name: user.name || null, image: user.image || null }} + className="h-8 w-8" + /> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <div className="flex items-center justify-start gap-2 p-2"> + <div className="flex flex-col space-y-1 leading-none"> + {user.name && <p className="font-medium">{user.name}</p>} + {user.username && ( + <p className="w-[200px] truncate text-sm text-muted-foreground"> + @{user.username} + </p> + )} + </div> + </div> + <DropdownMenuSeparator /> + <DropdownMenuItem + className="cursor-pointer" + onSelect={(event) => { + event.preventDefault() + signOut({ + callbackUrl: `${window.location.origin}/login`, + }) + }} + > + Sign out + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) +} \ No newline at end of file diff --git a/lib/auth.ts b/lib/auth.ts index 7763e341993a4e76ab9682097675d8f44e04f6ae..9a459b612e82b0eedd3b2408c59707dd385f4d40 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -6,6 +6,7 @@ import { NextAuthOptions } from "next-auth" import { Adapter } from "next-auth/adapters" import CredentialsProvider from 'next-auth/providers/credentials' import GitHubProvider from "next-auth/providers/github" +import { normalize } from "normalize-diacritics" export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db as any) as Adapter, @@ -42,7 +43,7 @@ export const authOptions: NextAuthOptions = { }); if (!user || !user.password) { - return null + throw new Error('user not found') } const isPasswordValid = await compare( @@ -51,7 +52,7 @@ export const authOptions: NextAuthOptions = { ) if (!isPasswordValid) { - return null + throw new Error('invalid password') } return { @@ -64,10 +65,44 @@ export const authOptions: NextAuthOptions = { ], secret: env.NEXTAUTH_SECRET, callbacks: { + async signIn({ user, account }) { + if (account?.provider === 'github') { + const { name, email } = user; + if (!name || !email) { + return false; + } + + let username = await normalize(name.toLowerCase().replace(/\s/g, '')); + + let isUnique = false; + while (!isUnique) { + const existingUserName = await db.user.findFirst({ + where: { + username, + NOT: { email }, + }, + }) + + if (existingUserName) { + username = `${username}${Math.floor(Math.random() * 1000)}` + } else { + isUnique = true; + } + } + + await db.user.update({ + where: { email }, + data: { username }, + }); + } + + return true; + }, async session({ token, session }) { if (token) { session.user.id = token.id session.user.name = token.name + session.user.username = token.username session.user.email = token.email session.user.image = token.picture } @@ -91,6 +126,7 @@ export const authOptions: NextAuthOptions = { return { id: dbUser.id, name: dbUser.name, + username: dbUser.username, email: dbUser.email, picture: dbUser.image, } diff --git a/package-lock.json b/package-lock.json index f3aa7f9768ce1388977e28f8975971a5a9912c77..001892b66f10fbd42c7eaf42112fb808b8855921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@auth/prisma-adapter": "^1.0.0", "@hookform/resolvers": "^3.1.0", "@prisma/client": "^4.15.0", + "@radix-ui/react-avatar": "^1.0.3", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-scroll-area": "^1.0.4", @@ -22,10 +23,11 @@ "bcrypt": "^5.1.0", "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", - "lucide-react": "^0.234.0", + "lucide-react": "^0.236.0", "next": "^13.4.4", "next-auth": "^4.22.1", "next-themes": "^0.2.1", + "normalize-diacritics": "^4.0.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.44.3", @@ -592,6 +594,32 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.0.3.tgz", + "integrity": "sha512-9ToF7YNex3Ste45LrAeTlKtONI9yVRt/zOS158iilIkW5K/Apeyb/TUQlcEFTEFvWr8Kzdi2ZYrm1/suiXPajQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "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-collection": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", @@ -4047,9 +4075,9 @@ } }, "node_modules/lucide-react": { - "version": "0.234.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.234.0.tgz", - "integrity": "sha512-7MtwK9zPXyvpHv9Tf0anraIjI9yRDdkfYIPt8KOC54NvimBAxbNpeIBb5/f+tZVIkN0hMgZdrAFugZe4YPZHcA==", + "version": "0.236.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.236.0.tgz", + "integrity": "sha512-himeKF7nVgOQ1BNcyBgk41E4/rcbmI6Zw8Q4o57nlynsFvIBA/DMacFpzKYdcyBReUj8jf08xTnCGyn/niLvwQ==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } @@ -4367,6 +4395,21 @@ "node": ">=6" } }, + "node_modules/normalize-diacritics": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/normalize-diacritics/-/normalize-diacritics-4.0.0.tgz", + "integrity": "sha512-PXJtdbPvDvthxCqsi/WyQ58pPqaOKQBxuEPFmkVRMQwHTMk4aJa2gLrh5H3455JzNO0tPCM0Paj/u+CH5Jjcuw==", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">= 14.x", + "npm": ">= 6.x" + }, + "funding": { + "url": "https://github.com/motss/lit-ntml?sponsor=1" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 926c0c1e2b5846a05df08ca7bab13f8b209b0a25..ef91fe612a04aeae7a0bb9cdd7e79cb4098cbd5b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@auth/prisma-adapter": "^1.0.0", "@hookform/resolvers": "^3.1.0", "@prisma/client": "^4.15.0", + "@radix-ui/react-avatar": "^1.0.3", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-scroll-area": "^1.0.4", @@ -25,10 +26,11 @@ "bcrypt": "^5.1.0", "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", - "lucide-react": "^0.234.0", + "lucide-react": "^0.236.0", "next": "^13.4.4", "next-auth": "^4.22.1", "next-themes": "^0.2.1", + "normalize-diacritics": "^4.0.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.44.3", @@ -51,4 +53,4 @@ "tailwindcss": "3.3.2", "typescript": "^5.1.3" } -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c9a096a1b283983b3be584087a1214737a68eb38..d48c1ca1166fb604bff5e30cd79ecc23094c6df5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,7 +47,7 @@ model Session { model User { id String @id @default(cuid()) name String? - username String? @unique @map("usernames") + username String? @unique email String? @unique emailVerified DateTime? password String? diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index 19ff225ad2b7dabd0e2a72204ab308e27494b0c0..4ca491133029ade06c1e3e43a717b38e54fdda6c 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -2,17 +2,20 @@ import { User } from "next-auth" import { JWT } from "next-auth/jwt" type UserId = string +type UserUsername = string | null | undefined declare module "next-auth/jwt" { interface JWT { - id: UserId + id: UserId, + username: UserUsername } } declare module "next-auth" { interface Session { user: User & { - id: UserId + id: UserId, + username: UserUsername } } } \ No newline at end of file