diff --git a/frontend/app/profile/account/page.tsx b/frontend/app/profile/account/page.tsx new file mode 100644 index 00000000..e025bc6c --- /dev/null +++ b/frontend/app/profile/account/page.tsx @@ -0,0 +1,3 @@ +export default function AccountPage() { + return <div>Account Page</div>; +} diff --git a/frontend/app/profile/appearance/page.tsx b/frontend/app/profile/appearance/page.tsx new file mode 100644 index 00000000..a8a5b219 --- /dev/null +++ b/frontend/app/profile/appearance/page.tsx @@ -0,0 +1,3 @@ +export default function AppearancePage() { + return <div>Appearance Page</div>; +} diff --git a/frontend/app/profile/display/page.tsx b/frontend/app/profile/display/page.tsx new file mode 100644 index 00000000..64e09cc4 --- /dev/null +++ b/frontend/app/profile/display/page.tsx @@ -0,0 +1,3 @@ +export default function DisplayPage() { + return <div>Display Page</div>; +} diff --git a/frontend/app/profile/layout.tsx b/frontend/app/profile/layout.tsx new file mode 100644 index 00000000..aba695ee --- /dev/null +++ b/frontend/app/profile/layout.tsx @@ -0,0 +1,54 @@ +import { Separator } from "@/components/ui/separator"; +import { SidebarNav } from "@/app/ui/sidebar-nav"; + +const sidebarNavItems = [ + { + title: "Profile", + href: "/profile", + }, + { + title: "Account", + href: "/profile/account", + }, + { + title: "Appearance", + href: "/profile/appearance", + }, + { + title: "Notifications", + href: "/profile/notifications", + }, + { + title: "Display", + href: "/profile/display", + }, +]; + +interface SettingsLayoutProps { + children: React.ReactNode; +} + +export default function SettingsLayout({ children }: SettingsLayoutProps) { + return ( + <> + <div className="md:hidden"> + <div className="space-y-6 p-10 pb-16">Can't be displayed.</div> + </div> + <div className="hidden pb-16 md:block"> + <div className="space-y-0.5"> + <h2 className="text-2xl font-bold tracking-tight">Settings</h2> + <p className="text-muted-foreground"> + Manage your account settings and set e-mail preferences. + </p> + </div> + <Separator className="my-6" /> + <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> + <aside className="lg:w-1/5"> + <SidebarNav items={sidebarNavItems} /> + </aside> + <div className="flex-1 lg:max-w-2xl">{children}</div> + </div> + </div> + </> + ); +} diff --git a/frontend/app/profile/notifications/page.tsx b/frontend/app/profile/notifications/page.tsx new file mode 100644 index 00000000..1b527372 --- /dev/null +++ b/frontend/app/profile/notifications/page.tsx @@ -0,0 +1,3 @@ +export default function NotificationsPage() { + return <div>Notifications Page</div>; +} diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx new file mode 100644 index 00000000..91287065 --- /dev/null +++ b/frontend/app/profile/page.tsx @@ -0,0 +1,48 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Stack } from "@/app/ui/layout/stack"; + +function AvatarSkeleton() { + return <Skeleton className="rounded-full h-20 w-20" />; +} + +type ProfileItemProps = { + title: string; + value: string; +}; + +function ProfileItem({ title, value }: ProfileItemProps) { + return ( + <Stack spacing={1} className="flex-initial w-96"> + <div className="text-xs text-muted-foreground">{title}: </div> + <Input defaultValue={value} /> + </Stack> + ); +} + +export default function ProfilePage() { + // Menu: min 100px + // Profile : the rest + return ( + <> + <main> + <form> + <Stack spacing={4}> + <Stack spacing={1}> + <div className="text-2xl">Profile</div> + <Separator /> + </Stack> + <AvatarSkeleton /> + <ProfileItem title="username" value="susami" /> + <ProfileItem title="email" value="susami@example.com" /> + <Button variant="secondary" className="flex-initial w-40"> + Save + </Button> + </Stack> + </form> + </main> + </> + ); +} diff --git a/frontend/app/ui/layout/stack.tsx b/frontend/app/ui/layout/stack.tsx new file mode 100644 index 00000000..408cd34f --- /dev/null +++ b/frontend/app/ui/layout/stack.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; + +const gaps = { + 0: "gap-0", + 1: "gap-1", + 2: "gap-2", + 3: "gap-3", + 4: "gap-4", + 5: "gap-5", + 6: "gap-6", + 7: "gap-7", + 8: "gap-8", + 9: "gap-9", + 10: "gap-10", + 11: "gap-11", + 12: "gap-12", + 14: "gap-14", + 16: "gap-16", + 20: "gap-20", + 24: "gap-24", + 28: "gap-28", + 32: "gap-32", + 36: "gap-36", + 40: "gap-40", + 44: "gap-44", + 48: "gap-48", + 52: "gap-52", + 56: "gap-56", + 60: "gap-60", + 64: "gap-64", + 72: "gap-72", + 80: "gap-80", + 96: "gap-96", +}; + +type StackProps = { + className?: string; + spacing?: keyof typeof gaps; + children: React.ReactNode; +}; + +export function Stack({ className, spacing = 1, children }: StackProps) { + return ( + <div className={cn(`flex flex-col ${gaps[spacing]}`, className)}> + {children} + </div> + ); +} + +// center +export function HStack({ className, spacing = 1, children }: StackProps) { + return ( + <div className={cn(`flex ${gaps[spacing]}`, className)}>{children}</div> + ); +} diff --git a/frontend/app/ui/nav.tsx b/frontend/app/ui/nav.tsx index 9038a325..736881a8 100644 --- a/frontend/app/ui/nav.tsx +++ b/frontend/app/ui/nav.tsx @@ -1,37 +1,42 @@ -import Image from "next/image"; import { ModeToggle } from "@/components/toggle-mode"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import { signOut } from "@/app/lib/actions"; +import { isLoggedIn } from "@/app/lib/session"; -export default function Nav() { +function AuthorizedMenu() { + return ( + <li className="flex gap-8 items-center"> + <Link href="/user">User List</Link> + <Link href="/room">ChatRoom List</Link> + <form action={signOut}> + <Button type="submit">Sign Out</Button> + </form> + <ModeToggle></ModeToggle> + </li> + ); +} + +function UnauthorizedMenu() { + return ( + <li className="flex gap-8 items-center"> + <Link href="/user/signup">Sign Up</Link> + <Link href="/login">Log In</Link> + <ModeToggle></ModeToggle> + </li> + ); +} + +export default async function Nav() { + const isAuthorized = await isLoggedIn(); return ( <header> <nav> <ul className="flex items-center justify-between"> - <li> - <Image - src="/vercel.svg" - alt="Vercel Logo" - className="dark:invert" - width={300 / 3} - height={68 / 3} - priority - /> - </li> - <li className="flex gap-8 items-center"> - <Link href="/">Home</Link> - <Link href="/user">User List</Link> - <Link href="/room">ChatRoom List</Link> - <Link href="/user/signup">Sign Up</Link> - <Link href="/playground/pong.html" target="_blank"> - Pong - </Link> - <form action={signOut}> - <Button type="submit">Sign Out</Button> - </form> - <ModeToggle></ModeToggle> - </li> + <Link href="/" className="font-black"> + Pong + </Link> + {isAuthorized ? <AuthorizedMenu /> : <UnauthorizedMenu />} </ul> </nav> </header> diff --git a/frontend/app/ui/sidebar-nav.tsx b/frontend/app/ui/sidebar-nav.tsx new file mode 100644 index 00000000..d2bed874 --- /dev/null +++ b/frontend/app/ui/sidebar-nav.tsx @@ -0,0 +1,44 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> { + items: { + href: string; + title: string; + }[]; +} + +export function SidebarNav({ className, items, ...props }: SidebarNavProps) { + const pathname = usePathname(); + + return ( + <nav + className={cn( + "flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1", + className, + )} + {...props} + > + {items.map((item) => ( + <Link + key={item.href} + href={item.href} + className={cn( + buttonVariants({ variant: "ghost" }), + pathname === item.href + ? "bg-muted hover:bg-muted" + : "hover:bg-transparent hover:underline", + "justify-start", + )} + > + {item.title} + </Link> + ))} + </nav> + ); +} diff --git a/frontend/components/ui/separator.tsx b/frontend/components/ui/separator.tsx new file mode 100644 index 00000000..9ac3b95f --- /dev/null +++ b/frontend/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef<typeof SeparatorPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref, + ) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + "shrink-0 bg-border", + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + className, + )} + {...props} + /> + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/frontend/components/ui/skeleton.tsx b/frontend/components/ui/skeleton.tsx new file mode 100644 index 00000000..2cdf440d --- /dev/null +++ b/frontend/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) { + return ( + <div + className={cn("animate-pulse rounded-md bg-muted", className)} + {...props} + /> + ); +} + +export { Skeleton }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1b6c8020..1567a952 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-scroll-area": "^1.0.5", + "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", "class-variance-authority": "^0.7.0", @@ -23,12 +24,14 @@ "react-dom": "^18", "socket.io-client": "^4.7.2", "tailwind-merge": "^1.14.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "uuid": "^9.0.1" }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^9.0.7", "autoprefixer": "^10", "eslint": "^8", "eslint-config-next": "14.0.2", @@ -858,6 +861,15 @@ "@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" + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", + "integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" }, "peerDependencies": { "@types/react": "*", @@ -1129,6 +1141,12 @@ "integrity": "sha512-s/FPdYRmZR8SjLWGMCuax7r3qCWQw9QKHzXVukAuuIJkXkDRwp+Pu5LMIVFi0Fxbav35WURicYr8u1QsoybnQw==", "devOptional": true }, + "node_modules/@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", + "dev": true + }, "node_modules/@typescript-eslint/parser": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.9.0.tgz", @@ -5083,6 +5101,18 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 503b7c36..7deae2d6 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-scroll-area": "^1.0.5", + "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", "class-variance-authority": "^0.7.0",