diff --git a/.eslintignore b/.eslintignore index 186ffad..0fb5d73 100644 --- a/.eslintignore +++ b/.eslintignore @@ -54,4 +54,6 @@ Thumbs.db .prettierignore .eslintignore -.github/ \ No newline at end of file +.github/ + +components/ui/ \ No newline at end of file diff --git a/actions/auth/login.ts b/actions/auth/login.ts index 93b2043..7e81d82 100644 --- a/actions/auth/login.ts +++ b/actions/auth/login.ts @@ -32,7 +32,7 @@ export async function login({ return { success: true, - message: 'Welcome! You are logged in.', + message: 'Login successful', }; } catch (error: unknown) { if (isRedirectError(error)) { diff --git a/actions/dashboard/logout.ts b/actions/dashboard/logout.ts new file mode 100644 index 0000000..dea7553 --- /dev/null +++ b/actions/dashboard/logout.ts @@ -0,0 +1,27 @@ +'use server'; + +import { ServerActionResponse } from '@/types/types'; +import { signOut } from '@/auth'; + +export async function logout(): Promise { + try { + await signOut({ redirect: false }); + + return { + success: true, + message: 'Logout successful', + }; + } catch (error: unknown) { + if (error instanceof Error) { + return { + success: false, + message: error.message, + }; + } + + return { + success: false, + message: 'Logout failed', + }; + } +} diff --git a/app/(protected)/dashboard/page.tsx b/app/(protected)/dashboard/page.tsx deleted file mode 100644 index 79af37b..0000000 --- a/app/(protected)/dashboard/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { auth, signOut } from '@/auth'; -import { DEFAULT_LOGGED_OUT_REDIRECT } from '@/constants/routes'; -import { Button } from '@radix-ui/themes'; - -export default async function DashboardPage() { - const session = await auth(); - - return ( -
-

Dashboard

-

Welcome, {JSON.stringify(session)}

- -
{ - 'use server'; - - await signOut({ - redirectTo: DEFAULT_LOGGED_OUT_REDIRECT, - }); - }} - > - -
-
- ); -} diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx index b87ff39..56f1b03 100644 --- a/app/auth/layout.tsx +++ b/app/auth/layout.tsx @@ -1,10 +1,11 @@ import type { Metadata } from 'next'; -import { Text, Flex, Box } from '@radix-ui/themes'; import { archivoNarrow } from '@/config/fonts'; +import { ThemeToggleButton } from '@/components/ThemeToggleButton'; +import SparklesText from '@/components/ui/sparkles-text'; export const metadata: Metadata = { - title: 'Authentication', - description: 'Login or sign up to access your account', + title: 'Clubspace | Auth', + description: 'Clubspace authentication', }; export default function AuthLayout({ @@ -13,24 +14,24 @@ export default function AuthLayout({ children: React.ReactNode; }>) { return ( - - - - - clubspace. - - - One platform, uniting campus clubs, creating community! - - +
+
+
+
+ + +
+

One platform, uniting campus clubs, creating community!

+
{children} - - +
+
); } diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index 495a45c..6e04726 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -1,6 +1,12 @@ import { LoginForm } from '@/components/auth/LoginForm'; import { login as loginServerAction } from '@/actions/auth/login'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Clubspace | Login', + description: 'Login to your account', +}; export default async function LoginPage() { - return ; + return ; } diff --git a/app/auth/register/page.tsx b/app/auth/register/page.tsx index c792d97..64e905d 100644 --- a/app/auth/register/page.tsx +++ b/app/auth/register/page.tsx @@ -1,6 +1,12 @@ import { RegisterForm } from '@/components/auth/RegisterForm'; import { register as registerServerAction } from '@/actions/auth/register'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Clubspace | Register', + description: 'Create an account', +}; export default async function RegisterPage() { - return ; + return ; } diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..bcf762a --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,18 @@ +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; +import { DashboardSidebar } from '@/components/dashboard/DashboardSidebar'; + +export default function DashboardLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
+ {children} +
+
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..3598449 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,14 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Dashboard', + description: 'Dashboard', +}; + +export default async function DashboardPage() { + return ( +
+

Dashboard

+
+ ); +} diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..de7733c --- /dev/null +++ b/app/dashboard/settings/page.tsx @@ -0,0 +1,14 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Dashboard | Settings', + description: 'Settings', +}; + +export default async function SettingsPage() { + return ( +
+

Settings

+
+ ); +} diff --git a/app/globals.css b/app/globals.css deleted file mode 100644 index 9e21358..0000000 --- a/app/globals.css +++ /dev/null @@ -1,3 +0,0 @@ -.radix-themes { - --default-font-family: var(--font-space-grotesk) !important; -} diff --git a/app/layout.tsx b/app/layout.tsx index 37584a2..a9a3bd0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,8 @@ -import '@radix-ui/themes/styles.css'; -import '@/app/globals.css'; +import '@/styles/globals.css'; import type { Metadata } from 'next'; -import { Container, Theme } from '@radix-ui/themes'; import { Toaster } from 'react-hot-toast'; import { spaceGrotesk } from '@/config/fonts'; +import { ThemeProvider } from '@/components/ThemeProvider'; export const metadata: Metadata = { title: 'Clubspace', @@ -16,30 +15,17 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - + + + - - - {children} - - + {children} + ); diff --git a/components.json b/components.json new file mode 100644 index 0000000..b003b48 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "styles/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/components/ThemeProvider.tsx b/components/ThemeProvider.tsx new file mode 100644 index 0000000..530befd --- /dev/null +++ b/components/ThemeProvider.tsx @@ -0,0 +1,9 @@ +'use client'; + +import * as React from 'react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import { type ThemeProviderProps } from 'next-themes/dist/types'; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/components/ThemeToggleButton.tsx b/components/ThemeToggleButton.tsx new file mode 100644 index 0000000..899ca4b --- /dev/null +++ b/components/ThemeToggleButton.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as React from 'react'; +import { MoonIcon, SunIcon } from '@radix-ui/react-icons'; +import { useTheme } from 'next-themes'; + +import { Button } from '@/components/ui/button'; + +export function ThemeToggleButton() { + const { theme, setTheme } = useTheme(); + + return ( + + ); +} diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx index c6dfe51..1e38851 100644 --- a/components/auth/LoginForm.tsx +++ b/components/auth/LoginForm.tsx @@ -7,16 +7,19 @@ import { loginSchema } from '@/schema/auth/schema'; import { ServerActionResponse } from '@/types/types'; import { LoginFormData } from '@/types/auth/types'; import toast from 'react-hot-toast'; -import { Text, Button, Flex, TextField, Card } from '@radix-ui/themes'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { DEFAULT_LOGGED_IN_REDIRECT } from '@/constants/routes'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; -interface LoginFormProps { - submitServerAction: (data: LoginFormData) => Promise; -} +type LoginFormProps = { + serverAction: (data: LoginFormData) => Promise; +}; -export const LoginForm: React.FC = ({ submitServerAction }) => { +export const LoginForm: React.FC = ({ serverAction }) => { const router = useRouter(); const [isPending, startTransition] = useTransition(); const { @@ -35,7 +38,7 @@ export const LoginForm: React.FC = ({ submitServerAction }) => { const onSubmit = async (data: LoginFormData) => { startTransition(async () => { try { - const { success, message } = await submitServerAction(data); + const { success, message } = await serverAction(data); if (success) { toast.success(message); @@ -56,60 +59,60 @@ export const LoginForm: React.FC = ({ submitServerAction }) => { }; return ( - -
- - - Sign in 🔐 - - - - - Email - - + + Login + + + +
+ + {errors.email?.message && ( - +

{errors.email.message} - +

)} - +
- - - Password - - + + {errors.password?.message && ( - +

{errors.password.message} - +

)} -
+ - +
+ - - Don't have an account?{' '} - Sign up - - - +

+ Don't have an account?{' '} + + Register + +

+
+ +
); }; diff --git a/components/auth/RegisterForm.tsx b/components/auth/RegisterForm.tsx index 7d7e98d..99a8681 100644 --- a/components/auth/RegisterForm.tsx +++ b/components/auth/RegisterForm.tsx @@ -7,18 +7,19 @@ import { registerSchema } from '@/schema/auth/schema'; import { ServerActionResponse } from '@/types/types'; import { RegisterFormData } from '@/types/auth/types'; import toast from 'react-hot-toast'; -import { Text, Button, Flex, TextField, Card } from '@radix-ui/themes'; import Link from 'next/link'; import { DEFAULT_LOGGED_OUT_REDIRECT } from '@/constants/routes'; import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; -interface RegisterFormProps { - submitServerAction: (data: RegisterFormData) => Promise; -} +type RegisterFormProps = { + serverAction: (data: RegisterFormData) => Promise; +}; -export const RegisterForm: React.FC = ({ - submitServerAction, -}) => { +export const RegisterForm: React.FC = ({ serverAction }) => { const [isPending, startTransition] = useTransition(); const router = useRouter(); @@ -39,7 +40,7 @@ export const RegisterForm: React.FC = ({ const onSubmit = async (data: RegisterFormData) => { startTransition(async () => { try { - const { success, message } = await submitServerAction(data); + const { success, message } = await serverAction(data); if (success) { toast.success(message); @@ -60,81 +61,70 @@ export const RegisterForm: React.FC = ({ }; return ( - -
- - - Sign up 📝 - - - - - - Name - - - {errors.name?.message && ( - - {errors.name.message} - - )} - + + + Register + + + +
+ + + {errors.name?.message && ( +

+ {errors.name.message} +

+ )} +
- - - Email - - - {errors.email?.message && ( - - {errors.email.message} - - )} - +
+ + + {errors.email?.message && ( +

+ {errors.email.message} +

+ )} +
- - - Password - - - {errors.password?.message && ( - - {errors.password.message} - - )} - -
+
+ + + {errors.password?.message && ( +

+ {errors.password.message} +

+ )} +
- +
- - Already have an account? Sign in - - - - +

+ Already have an account?{' '} + + Login + +

+
+ +
); }; diff --git a/components/dashboard/DashboardSidebar.tsx b/components/dashboard/DashboardSidebar.tsx new file mode 100644 index 0000000..4aba40c --- /dev/null +++ b/components/dashboard/DashboardSidebar.tsx @@ -0,0 +1,87 @@ +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarMenuItem, + SidebarMenuButton, + SidebarHeader, + SidebarMenu, + SidebarGroupContent, + SidebarFooter, +} from '@/components/ui/sidebar'; +import { ChevronsUpDown, Home, Settings } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { LogoutDropdownMenuItem } from '@/components/dashboard/LogoutDropdownMenuItem'; +import { logout as logoutServerAction } from '@/actions/dashboard/logout'; +import Link from 'next/link'; +import { auth } from '@/auth'; +import { ThemeToggleButton } from '@/components/ThemeToggleButton'; +import { Button } from '../ui/button'; + +const items = [ + { + title: 'Home', + url: '/dashboard', + icon: Home, + }, + { + title: 'Settings', + url: '/dashboard/settings', + icon: Settings, + }, +]; + +export async function DashboardSidebar() { + const session = await auth(); + + return ( + + + + + + + + + + + + + + + + + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + + + + + ); +} diff --git a/components/dashboard/LogoutDropdownMenuItem.tsx b/components/dashboard/LogoutDropdownMenuItem.tsx new file mode 100644 index 0000000..3c2eb13 --- /dev/null +++ b/components/dashboard/LogoutDropdownMenuItem.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; +import { LogOut } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { ServerActionResponse } from '@/types/types'; +import { DEFAULT_LOGGED_OUT_REDIRECT } from '@/constants/routes'; +import { useRouter } from 'next/navigation'; + +type LogoutDropdownMenuItemProps = { + serverAction: () => Promise; +}; + +export const LogoutDropdownMenuItem: React.FC = ({ + serverAction, +}) => { + const router = useRouter(); + + const handleLogout = async () => { + try { + const { success, message } = await serverAction(); + + if (success) { + toast.success(message); + router.push(DEFAULT_LOGGED_OUT_REDIRECT); + } else { + toast.error(message); + } + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error('An unexpected error occurred. Please try again.'); + } + } + }; + + return ( + + + Logout + + ); +}; diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..404fa02 --- /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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..5b1de3b --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,57 @@ +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'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..cdfabe8 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/components/ui/dot-pattern.tsx b/components/ui/dot-pattern.tsx new file mode 100644 index 0000000..ead5438 --- /dev/null +++ b/components/ui/dot-pattern.tsx @@ -0,0 +1,56 @@ +import { useId } from 'react'; + +import { cn } from '@/lib/utils'; + +interface DotPatternProps { + width?: any; + height?: any; + x?: any; + y?: any; + cx?: any; + cy?: any; + cr?: any; + className?: string; + [key: string]: any; +} +export function DotPattern({ + width = 16, + height = 16, + x = 0, + y = 0, + cx = 1, + cy = 1, + cr = 1, + className, + ...props +}: DotPatternProps) { + const id = useId(); + + return ( + + ); +} + +export default DotPattern; diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ce8444b --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,205 @@ +'use client'; + +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from '@radix-ui/react-icons'; + +import { cn } from '@/lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0', + inset && 'pl-8', + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..501e8d9 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..7114fb0 --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,26 @@ +'use client'; + +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'; + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 0000000..2ba5a15 --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,164 @@ +'use client'; + +import * as React from 'react'; +import { + CaretSortIcon, + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from '@radix-ui/react-icons'; +import * as SelectPrimitive from '@radix-ui/react-select'; + +import { cn } from '@/lib/utils'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..8ee9f4f --- /dev/null +++ b/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, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = 'horizontal', decorative = true, ...props }, + ref, + ) => ( + + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..7be9432 --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,140 @@ +'use client'; + +import * as React from 'react'; +import * as SheetPrimitive from '@radix-ui/react-dialog'; +import { Cross2Icon } from '@radix-ui/react-icons'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: + 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: + 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + }, + }, + defaultVariants: { + side: 'right', + }, + }, +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'right', className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = 'SheetHeader'; + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = 'SheetFooter'; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx new file mode 100644 index 0000000..e68d0fb --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,772 @@ +'use client'; + +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { VariantProps, cva } from 'class-variance-authority'; +import { PanelLeft } from 'lucide-react'; + +import { useIsMobile } from '@/hooks/use-mobile'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { Sheet, SheetContent } from '@/components/ui/sheet'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +const SIDEBAR_COOKIE_NAME = 'sidebar:state'; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = '16rem'; +const SIDEBAR_WIDTH_MOBILE = '18rem'; +const SIDEBAR_WIDTH_ICON = '3rem'; +const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; + +type SidebarContext = { + state: 'expanded' | 'collapsed'; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.'); + } + + return context; +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref, + ) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + if (setOpenProp) { + return setOpenProp?.( + typeof value === 'function' ? value(open) : value, + ); + } + + _setOpen(value); + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed'; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + ], + ); + + return ( + + +
+ {children} +
+
+
+ ); + }, +); +SidebarProvider.displayName = 'SidebarProvider'; + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + side?: 'left' | 'right'; + variant?: 'sidebar' | 'floating' | 'inset'; + collapsible?: 'offcanvas' | 'icon' | 'none'; + } +>( + ( + { + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + ...props + }, + ref, + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === 'none') { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); + }, +); +Sidebar.displayName = 'Sidebar'; + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +}); +SidebarTrigger.displayName = 'SidebarTrigger'; + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<'button'> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( +