INit
This commit is contained in:
30
resources/js/app.tsx
Normal file
30
resources/js/app.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import '../css/app.css';
|
||||
|
||||
import { createInertiaApp } from '@inertiajs/react';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { initializeTheme } from './hooks/use-appearance';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => (title ? `${title} | ${appName}` : appName),
|
||||
resolve: (name) =>
|
||||
resolvePageComponent(
|
||||
`./pages/${name}.tsx`,
|
||||
import.meta.glob('./pages/**/*.tsx'),
|
||||
),
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el);
|
||||
|
||||
root.render(<App {...props} />);
|
||||
},
|
||||
progress: {
|
||||
color: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--primary')
|
||||
.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
// This will set light / dark mode on load...
|
||||
initializeTheme();
|
||||
24
resources/js/components/alert-error.tsx
Normal file
24
resources/js/components/alert-error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
|
||||
export default function AlertError({
|
||||
errors,
|
||||
title,
|
||||
}: {
|
||||
errors: string[];
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>{title || 'Something went wrong.'}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
{Array.from(new Set(errors)).map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
25
resources/js/components/app-content.tsx
Normal file
25
resources/js/components/app-content.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SidebarInset } from '@/components/ui/sidebar';
|
||||
import * as React from 'react';
|
||||
|
||||
interface AppContentProps extends React.ComponentProps<'main'> {
|
||||
variant?: 'header' | 'sidebar';
|
||||
}
|
||||
|
||||
export function AppContent({
|
||||
variant = 'header',
|
||||
children,
|
||||
...props
|
||||
}: AppContentProps) {
|
||||
if (variant === 'sidebar') {
|
||||
return <SidebarInset {...props}>{children}</SidebarInset>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
274
resources/js/components/app-header.tsx
Normal file
274
resources/js/components/app-header.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { Icon } from '@/components/icon';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { UserMenuContent } from '@/components/user-menu-content';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dashboard } from '@/routes';
|
||||
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
|
||||
import AppLogo from './app-logo';
|
||||
import AppLogoIcon from './app-logo-icon';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
];
|
||||
|
||||
const rightNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/react-starter-kit',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#react',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
|
||||
const activeItemStyles =
|
||||
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
|
||||
|
||||
interface AppHeaderProps {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
||||
const page = usePage<SharedData>();
|
||||
const { auth } = page.props;
|
||||
const getInitials = useInitials();
|
||||
return (
|
||||
<>
|
||||
<div className="border-b border-sidebar-border/80">
|
||||
<div className="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
|
||||
{/* Mobile Menu */}
|
||||
<div className="lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-2 h-[34px] w-[34px]"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
Navigation Menu
|
||||
</SheetTitle>
|
||||
<SheetHeader className="flex justify-start text-left">
|
||||
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
|
||||
</SheetHeader>
|
||||
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
|
||||
<div className="flex h-full flex-col justify-between text-sm">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{mainNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="flex items-center space-x-2 font-medium"
|
||||
>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
iconNode={item.icon}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
)}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4">
|
||||
{rightNavItems.map((item) => (
|
||||
<a
|
||||
key={item.title}
|
||||
href={
|
||||
typeof item.href ===
|
||||
'string'
|
||||
? item.href
|
||||
: item.href.url
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 font-medium"
|
||||
>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
iconNode={item.icon}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
)}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={dashboard()}
|
||||
prefetch
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<AppLogo />
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="ml-6 hidden h-full items-center space-x-6 lg:flex">
|
||||
<NavigationMenu className="flex h-full items-stretch">
|
||||
<NavigationMenuList className="flex h-full items-stretch space-x-2">
|
||||
{mainNavItems.map((item, index) => (
|
||||
<NavigationMenuItem
|
||||
key={index}
|
||||
className="relative flex h-full items-center"
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
navigationMenuTriggerStyle(),
|
||||
page.url ===
|
||||
(typeof item.href ===
|
||||
'string'
|
||||
? item.href
|
||||
: item.href.url) &&
|
||||
activeItemStyles,
|
||||
'h-9 cursor-pointer px-3',
|
||||
)}
|
||||
>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
iconNode={item.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
{item.title}
|
||||
</Link>
|
||||
{page.url === item.href && (
|
||||
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"></div>
|
||||
)}
|
||||
</NavigationMenuItem>
|
||||
))}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
<div className="relative flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group h-9 w-9 cursor-pointer"
|
||||
>
|
||||
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
|
||||
</Button>
|
||||
<div className="hidden lg:flex">
|
||||
{rightNavItems.map((item) => (
|
||||
<TooltipProvider
|
||||
key={item.title}
|
||||
delayDuration={0}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<a
|
||||
href={
|
||||
typeof item.href ===
|
||||
'string'
|
||||
? item.href
|
||||
: item.href.url
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group ml-1 inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium text-accent-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
iconNode={item.icon}
|
||||
className="size-5 opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-10 rounded-full p-1"
|
||||
>
|
||||
<Avatar className="size-8 overflow-hidden rounded-full">
|
||||
<AvatarImage
|
||||
src={auth.user.avatar}
|
||||
alt={auth.user.name}
|
||||
/>
|
||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
{getInitials(auth.user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end">
|
||||
<UserMenuContent user={auth.user} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{breadcrumbs.length > 1 && (
|
||||
<div className="flex w-full border-b border-sidebar-border/70">
|
||||
<div className="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl">
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
resources/js/components/app-logo-icon.tsx
Normal file
13
resources/js/components/app-logo-icon.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { SVGAttributes } from 'react';
|
||||
|
||||
export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 40 42" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.2 5.63325L8.6 0.855469L0 5.63325V32.1434L16.2 41.1434L32.4 32.1434V23.699L40 19.4767V9.85547L31.4 5.07769L22.8 9.85547V18.2999L17.2 21.411V5.63325ZM38 18.2999L32.4 21.411V15.2545L38 12.1434V18.2999ZM36.9409 10.4439L31.4 13.5221L25.8591 10.4439L31.4 7.36561L36.9409 10.4439ZM24.8 18.2999V12.1434L30.4 15.2545V21.411L24.8 18.2999ZM23.8 20.0323L29.3409 23.1105L16.2 30.411L10.6591 27.3328L23.8 20.0323ZM7.6 27.9212L15.2 32.1434V38.2999L2 30.9666V7.92116L7.6 11.0323V27.9212ZM8.6 9.29991L3.05913 6.22165L8.6 3.14339L14.1409 6.22165L8.6 9.29991ZM30.4 24.8101L17.2 32.1434V38.2999L30.4 30.9666V24.8101ZM9.6 11.0323L15.2 7.92117V22.5221L9.6 25.6333V11.0323Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
resources/js/components/app-logo.tsx
Normal file
16
resources/js/components/app-logo.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import AppLogoIcon from './app-logo-icon';
|
||||
|
||||
export default function AppLogo() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
|
||||
</div>
|
||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
||||
Laravel Starter Kit
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
resources/js/components/app-shell.tsx
Normal file
20
resources/js/components/app-shell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { SharedData } from '@/types';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'header' | 'sidebar';
|
||||
}
|
||||
|
||||
export function AppShell({ children, variant = 'header' }: AppShellProps) {
|
||||
const isOpen = usePage<SharedData>().props.sidebarOpen;
|
||||
|
||||
if (variant === 'header') {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SidebarProvider defaultOpen={isOpen}>{children}</SidebarProvider>;
|
||||
}
|
||||
18
resources/js/components/app-sidebar-header.tsx
Normal file
18
resources/js/components/app-sidebar-header.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
export function AppSidebarHeader({
|
||||
breadcrumbs = [],
|
||||
}: {
|
||||
breadcrumbs?: BreadcrumbItemType[];
|
||||
}) {
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/50 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
77
resources/js/components/app-sidebar.tsx
Normal file
77
resources/js/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NavFooter } from '@/components/nav-footer';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import dashboard from '@/routes/dashboard';
|
||||
import dashboardcarousel from '@/routes/dashboard/carousel';
|
||||
import testimonial from '@/routes/testimonial';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { BookOpen, Folder, GalleryHorizontal, LayoutGrid } from 'lucide-react';
|
||||
import AppLogo from './app-logo';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard.index(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Home Page Slider',
|
||||
href: dashboardcarousel.index(),
|
||||
icon: GalleryHorizontal,
|
||||
},
|
||||
{
|
||||
title: 'Testimonial',
|
||||
href: testimonial.index(),
|
||||
icon: GalleryHorizontal,
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/react-starter-kit',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#react',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link href={dashboard.index()} prefetch>
|
||||
<AppLogo />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain items={mainNavItems} />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter items={footerNavItems} className="mt-auto" />
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
67
resources/js/components/appearance-dropdown.tsx
Normal file
67
resources/js/components/appearance-dropdown.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
export default function AppearanceToggleDropdown({
|
||||
className = '',
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
|
||||
const getCurrentIcon = () => {
|
||||
switch (appearance) {
|
||||
case 'dark':
|
||||
return <Moon className="h-5 w-5" />;
|
||||
case 'light':
|
||||
return <Sun className="h-5 w-5" />;
|
||||
default:
|
||||
return <Monitor className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-md"
|
||||
>
|
||||
{getCurrentIcon()}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => updateAppearance('light')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Sun className="h-5 w-5" />
|
||||
Light
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateAppearance('dark')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Moon className="h-5 w-5" />
|
||||
Dark
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateAppearance('system')}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
System
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
resources/js/components/appearance-tabs.tsx
Normal file
43
resources/js/components/appearance-tabs.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Appearance, useAppearance } from '@/hooks/use-appearance';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LucideIcon, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
export default function AppearanceToggleTab({
|
||||
className = '',
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
|
||||
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
||||
{ value: 'light', icon: Sun, label: 'Light' },
|
||||
{ value: 'dark', icon: Moon, label: 'Dark' },
|
||||
{ value: 'system', icon: Monitor, label: 'System' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{tabs.map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => updateAppearance(value)}
|
||||
className={cn(
|
||||
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
|
||||
appearance === value
|
||||
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
|
||||
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
||||
)}
|
||||
>
|
||||
<Icon className="-ml-1 h-4 w-4" />
|
||||
<span className="ml-1.5 text-sm">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
resources/js/components/breadcrumbs.tsx
Normal file
49
resources/js/components/breadcrumbs.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export function Breadcrumbs({
|
||||
breadcrumbs,
|
||||
}: {
|
||||
breadcrumbs: BreadcrumbItemType[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{breadcrumbs.length > 0 && (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((item, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>
|
||||
{item.title}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={item.href}>
|
||||
{item.title}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{!isLast && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
120
resources/js/components/delete-user.tsx
Normal file
120
resources/js/components/delete-user.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Form } from '@inertiajs/react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export default function DeleteUser() {
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall
|
||||
title="Delete account"
|
||||
description="Delete your account and all of its resources"
|
||||
/>
|
||||
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
|
||||
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
|
||||
<p className="font-medium">Warning</p>
|
||||
<p className="text-sm">
|
||||
Please proceed with caution, this cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
data-test="delete-user-button"
|
||||
>
|
||||
Delete account
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
Are you sure you want to delete your account?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Once your account is deleted, all of its resources
|
||||
and data will also be permanently deleted. Please
|
||||
enter your password to confirm you would like to
|
||||
permanently delete your account.
|
||||
</DialogDescription>
|
||||
|
||||
<Form
|
||||
{...ProfileController.destroy.form()}
|
||||
options={{
|
||||
preserveScroll: true,
|
||||
}}
|
||||
onError={() => passwordInput.current?.focus()}
|
||||
resetOnSuccess
|
||||
className="space-y-6"
|
||||
>
|
||||
{({ resetAndClearErrors, processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="sr-only"
|
||||
>
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
ref={passwordInput}
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
resetAndClearErrors()
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={processing}
|
||||
asChild
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
data-test="confirm-delete-user-button"
|
||||
>
|
||||
Delete account
|
||||
</button>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
resources/js/components/heading-small.tsx
Normal file
16
resources/js/components/heading-small.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export default function HeadingSmall({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<header>
|
||||
<h3 className="mb-0.5 text-base font-medium">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
16
resources/js/components/heading.tsx
Normal file
16
resources/js/components/heading.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export default function Heading({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8 space-y-0.5">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
resources/js/components/icon.tsx
Normal file
15
resources/js/components/icon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type LucideProps } from 'lucide-react';
|
||||
import { type ComponentType } from 'react';
|
||||
|
||||
interface IconProps extends Omit<LucideProps, 'ref'> {
|
||||
iconNode: ComponentType<LucideProps>;
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
iconNode: IconComponent,
|
||||
className,
|
||||
...props
|
||||
}: IconProps) {
|
||||
return <IconComponent className={cn('h-4 w-4', className)} {...props} />;
|
||||
}
|
||||
17
resources/js/components/input-error.tsx
Normal file
17
resources/js/components/input-error.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type HTMLAttributes } from 'react';
|
||||
|
||||
export default function InputError({
|
||||
message,
|
||||
className = '',
|
||||
...props
|
||||
}: HTMLAttributes<HTMLParagraphElement> & { message?: string }) {
|
||||
return message ? (
|
||||
<p
|
||||
{...props}
|
||||
className={cn('text-sm text-red-600 dark:text-red-400', className)}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
155
resources/js/components/layout/Footer.tsx
Normal file
155
resources/js/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { about, artOfWeaving, contact, faq, home, product } from '@/routes';
|
||||
import logo from '@asset/img/logo/soorya.png';
|
||||
import { SiFacebook, SiInstagram } from '@icons-pack/react-simple-icons';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { MoveRight } from 'lucide-react';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-primary">
|
||||
<div className="mx-auto grid max-w-screen-2xl grid-cols-4 gap-8 px-12 py-8 max-md:grid-cols-1 max-md:grid-rows-4 max-md:px-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<img src={logo} alt="Soorya Carpet Logo" className="w-32" />
|
||||
<div className="flex flex-col gap-4 text-sm text-white">
|
||||
<p>
|
||||
A beautiful home deserves beautiful carpets Since
|
||||
1974, crafting the finest handmade Nepalese carpets
|
||||
</p>
|
||||
<ul>
|
||||
<li>Soorya Carpet Industries</li>
|
||||
<li>Address: Thapathali, Kathmandu</li>
|
||||
<li>Phone: [+977-1] 4780923, 4783003</li>
|
||||
<li>Email: sooryacarpets@gmail.com</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="text-lg font-medium text-white">Quick Link</p>
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
<li>
|
||||
<Link href={home()} className="text-neutral-300">
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={contact()} className="text-neutral-300">
|
||||
Contact US
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={about()} className="text-neutral-300">
|
||||
About Us
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={artOfWeaving()}
|
||||
className="text-neutral-300"
|
||||
>
|
||||
Art of Weaving
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={product()} className="text-neutral-300">
|
||||
Products
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={home()} className="text-neutral-300">
|
||||
Bespoke Design
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={home()} className="text-neutral-300">
|
||||
Design Gallery / E-Catalog
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={faq()} className="text-neutral-300">
|
||||
FAQ
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-medium text-white">
|
||||
Certifications & Trust Badges
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<form className="flex flex-col gap-2">
|
||||
<Label className="text-lg font-medium text-white">
|
||||
Newsletter/Updates
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 max-sm:flex-col">
|
||||
<Input
|
||||
id="newsletter"
|
||||
placeholder="Email Address"
|
||||
className="bg-white"
|
||||
/>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="bg-transparent text-white max-sm:w-full"
|
||||
>
|
||||
Subscribe <MoveRight />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-base font-medium text-white">
|
||||
Legal
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1 text-sm text-neutral-300">
|
||||
<li>
|
||||
<Link href="privacy-policy">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="terms-and-condition">
|
||||
Terms & Conditions Return
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={faq()}>
|
||||
Policy (from FAQ)Privacy Policy (from
|
||||
FAQ)
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mx-auto my-4 max-w-screen-2xl border-neutral-500 px-12" />
|
||||
<div className="max-auto flex max-w-screen-2xl items-center justify-between px-12 py-4 max-md:flex-col max-md:gap-4 max-md:px-6">
|
||||
<p className="text-xs text-white">
|
||||
© {new Date().getFullYear()} Soorya Carpet Industries.
|
||||
All rights reserved.
|
||||
</p>
|
||||
<ul className="flex items-center gap-4">
|
||||
<li>
|
||||
<Link
|
||||
href="https://www.facebook.com/sooryacarpet/?locale=ne_NP"
|
||||
target="_blank"
|
||||
>
|
||||
<SiFacebook color="#d4d4d4" size={16} />
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="https://www.instagram.com/kaleen_carpet/"
|
||||
target="_blank"
|
||||
>
|
||||
<SiInstagram color="#d4d4d4" size={16} />
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
151
resources/js/components/layout/Navbar.tsx
Normal file
151
resources/js/components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
// import { dashboard, login, register } from '@/routes';
|
||||
// import { SharedData } from '@/types';
|
||||
// import { Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import { about, artOfWeaving, contact, faq, home, product } from '@/routes';
|
||||
import logo from '@asset/img/logo/soorya.png';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Mail, MapPin, Menu, Phone } from 'lucide-react';
|
||||
|
||||
export default function Navbar() {
|
||||
// const { auth } = usePage<SharedData>().props;
|
||||
return (
|
||||
<>
|
||||
<header className="mx-auto w-full text-sm not-has-[nav]:hidden">
|
||||
<div className="bg-primary py-1.5 text-white/60 max-md:hidden">
|
||||
<div className="mx-auto max-w-screen-2xl px-12 max-md:px-2">
|
||||
<ul className="flex items-center gap-4">
|
||||
<li>
|
||||
<a
|
||||
href="tel:01-4220087"
|
||||
className="flex items-center gap-1 text-xs"
|
||||
>
|
||||
<Phone size={14} />
|
||||
01-4220087
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="mailto:info@soorya.com"
|
||||
className="flex items-center gap-1 text-xs"
|
||||
>
|
||||
<Mail size={14} />
|
||||
info@soorya.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-1 text-xs"
|
||||
>
|
||||
<MapPin size={14} />
|
||||
Thapathali, Kathmandu, Nepal
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-screen-2xl px-12 py-2 text-base not-has-[nav]:hidden max-md:px-4">
|
||||
<nav className="flex items-center justify-between gap-2">
|
||||
<Link href={home()}>
|
||||
<img
|
||||
src={logo}
|
||||
alt="Soorya Carpet Logo"
|
||||
className="w-36"
|
||||
/>
|
||||
</Link>
|
||||
<ul className="flex items-center gap-6 max-md:hidden">
|
||||
<li>
|
||||
<Link href={about()}>About Us</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={artOfWeaving()}>
|
||||
Art of Weaving
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={product()}>Our Products</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={about()}>Bespoke Design</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={faq()}>FAQs</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={contact()}>Contact Us</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger className="md:hidden">
|
||||
<Menu size={24} />
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-full">
|
||||
<SheetTitle className="hidden">Menu</SheetTitle>
|
||||
<SheetDescription className="hidden">
|
||||
Navigation Menu Item
|
||||
</SheetDescription>
|
||||
<ul className="mt-20 flex flex-col items-center justify-center gap-6">
|
||||
<li>
|
||||
<Link href={about()}>About Us</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={artOfWeaving()}>
|
||||
Art of Weaving
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={product()}>
|
||||
Our Products
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={about()}>
|
||||
Bespoke Design
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={faq()}>FAQs</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={contact()}>Contact Us</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* {auth.user ? (
|
||||
<Link
|
||||
href={dashboard()}
|
||||
className="inline-block rounded-sm border border-[#19140035] px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#1915014a] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:border-[#62605b]"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href={login()}
|
||||
className="inline-block rounded-sm border border-transparent px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#19140035] dark:text-[#EDEDEC] dark:hover:border-[#3E3E3A]"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href={register()}
|
||||
className="inline-block rounded-sm border border-[#19140035] px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#1915014a] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:border-[#62605b]"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)} */}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
resources/js/components/nav-footer.tsx
Normal file
56
resources/js/components/nav-footer.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Icon } from '@/components/icon';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { type NavItem } from '@/types';
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export function NavFooter({
|
||||
items,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SidebarGroup> & {
|
||||
items: NavItem[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup
|
||||
{...props}
|
||||
className={`group-data-[collapsible=icon]:p-0 ${className || ''}`}
|
||||
>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
|
||||
>
|
||||
<a
|
||||
href={
|
||||
typeof item.href === 'string'
|
||||
? item.href
|
||||
: item.href.url
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
iconNode={item.icon}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
)}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
38
resources/js/components/nav-main.tsx
Normal file
38
resources/js/components/nav-main.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
|
||||
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
||||
const page = usePage();
|
||||
return (
|
||||
<SidebarGroup className="px-2 py-0">
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={page.url.startsWith(
|
||||
typeof item.href === 'string'
|
||||
? item.href
|
||||
: item.href.url,
|
||||
)}
|
||||
tooltip={{ children: item.title }}
|
||||
>
|
||||
<Link href={item.href} prefetch>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
54
resources/js/components/nav-user.tsx
Normal file
54
resources/js/components/nav-user.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { UserInfo } from '@/components/user-info';
|
||||
import { UserMenuContent } from '@/components/user-menu-content';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { type SharedData } from '@/types';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
export function NavUser() {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
const { state } = useSidebar();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="group text-sidebar-accent-foreground data-[state=open]:bg-sidebar-accent"
|
||||
>
|
||||
<UserInfo user={auth.user} />
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="end"
|
||||
side={
|
||||
isMobile
|
||||
? 'bottom'
|
||||
: state === 'collapsed'
|
||||
? 'left'
|
||||
: 'bottom'
|
||||
}
|
||||
>
|
||||
<UserMenuContent user={auth.user} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
62
resources/js/components/pages/Home/Swiper.tsx
Normal file
62
resources/js/components/pages/Home/Swiper.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { MoveRightIcon } from 'lucide-react';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/navigation';
|
||||
import { Navigation } from 'swiper/modules';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
export default function SwiperCarousel({
|
||||
item,
|
||||
}: {
|
||||
item: [{ image_url: string; alt: string }];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="relative mx-auto max-w-screen-2xl overflow-clip"
|
||||
id="hero_swiper"
|
||||
>
|
||||
<Swiper
|
||||
// spaceBetween={50}
|
||||
slidesPerView={1}
|
||||
loop={true}
|
||||
autoplay={{
|
||||
delay: 1500,
|
||||
}}
|
||||
className="w-full"
|
||||
navigation={true}
|
||||
modules={[Navigation]}
|
||||
>
|
||||
{item.map((e) => (
|
||||
<SwiperSlide className="relative !w-full overflow-clip">
|
||||
<img
|
||||
src={e.image_url}
|
||||
alt={e.alt}
|
||||
className="mx-auto aspect-[16/6.5] h-full !w-full max-w-screen-2xl object-cover object-center max-sm:aspect-video"
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
<div className="absolute bottom-7 left-12 z-50 flex w-md flex-col gap-6 rounded-lg bg-[#FFF5F1]/80 px-12 py-6 font-serif">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-4xl font-medium">
|
||||
Feel More Like Home
|
||||
</h1>
|
||||
<p className="text-xl leading-tight">
|
||||
Carpets that are made with care from ground up
|
||||
</p>
|
||||
</div>
|
||||
<Link href="">
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
size={'lg'}
|
||||
className="cursor-pointer border border-primary bg-transparent text-lg ring-primary"
|
||||
>
|
||||
Discover More <MoveRightIcon />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
resources/js/components/pages/Home/Testimonial.tsx
Normal file
8
resources/js/components/pages/Home/Testimonial.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function Testimonial() {
|
||||
return (
|
||||
<>
|
||||
<h4>Testimonials</h4>
|
||||
<div></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
resources/js/components/pages/Home/index.ts
Normal file
2
resources/js/components/pages/Home/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Swiper } from './Swiper';
|
||||
export { default as Testimonial } from './Testimonial';
|
||||
23
resources/js/components/text-link.tsx
Normal file
23
resources/js/components/text-link.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
type LinkProps = ComponentProps<typeof Link>;
|
||||
|
||||
export default function TextLink({
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
'text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
164
resources/js/components/two-factor-recovery-codes.tsx
Normal file
164
resources/js/components/two-factor-recovery-codes.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||
import { Form } from '@inertiajs/react';
|
||||
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import AlertError from './alert-error';
|
||||
|
||||
interface TwoFactorRecoveryCodesProps {
|
||||
recoveryCodesList: string[];
|
||||
fetchRecoveryCodes: () => Promise<void>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export default function TwoFactorRecoveryCodes({
|
||||
recoveryCodesList,
|
||||
fetchRecoveryCodes,
|
||||
errors,
|
||||
}: TwoFactorRecoveryCodesProps) {
|
||||
const [codesAreVisible, setCodesAreVisible] = useState<boolean>(false);
|
||||
const codesSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible;
|
||||
|
||||
const toggleCodesVisibility = useCallback(async () => {
|
||||
if (!codesAreVisible && !recoveryCodesList.length) {
|
||||
await fetchRecoveryCodes();
|
||||
}
|
||||
|
||||
setCodesAreVisible(!codesAreVisible);
|
||||
|
||||
if (!codesAreVisible) {
|
||||
setTimeout(() => {
|
||||
codesSectionRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [codesAreVisible, recoveryCodesList.length, fetchRecoveryCodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recoveryCodesList.length) {
|
||||
fetchRecoveryCodes();
|
||||
}
|
||||
}, [recoveryCodesList.length, fetchRecoveryCodes]);
|
||||
|
||||
const RecoveryCodeIconComponent = codesAreVisible ? EyeOff : Eye;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-3">
|
||||
<LockKeyhole className="size-4" aria-hidden="true" />
|
||||
2FA Recovery Codes
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recovery codes let you regain access if you lose your 2FA
|
||||
device. Store them in a secure password manager.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3 select-none sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button
|
||||
onClick={toggleCodesVisibility}
|
||||
className="w-fit"
|
||||
aria-expanded={codesAreVisible}
|
||||
aria-controls="recovery-codes-section"
|
||||
>
|
||||
<RecoveryCodeIconComponent
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{codesAreVisible ? 'Hide' : 'View'} Recovery Codes
|
||||
</Button>
|
||||
|
||||
{canRegenerateCodes && (
|
||||
<Form
|
||||
{...regenerateRecoveryCodes.form()}
|
||||
options={{ preserveScroll: true }}
|
||||
onSuccess={fetchRecoveryCodes}
|
||||
>
|
||||
{({ processing }) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
aria-describedby="regenerate-warning"
|
||||
>
|
||||
<RefreshCw /> Regenerate Codes
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
id="recovery-codes-section"
|
||||
className={`relative overflow-hidden transition-all duration-300 ${codesAreVisible ? 'h-auto opacity-100' : 'h-0 opacity-0'}`}
|
||||
aria-hidden={!codesAreVisible}
|
||||
>
|
||||
<div className="mt-3 space-y-3">
|
||||
{errors?.length ? (
|
||||
<AlertError errors={errors} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={codesSectionRef}
|
||||
className="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm"
|
||||
role="list"
|
||||
aria-label="Recovery codes"
|
||||
>
|
||||
{recoveryCodesList.length ? (
|
||||
recoveryCodesList.map((code, index) => (
|
||||
<div
|
||||
key={index}
|
||||
role="listitem"
|
||||
className="select-text"
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
className="space-y-2"
|
||||
aria-label="Loading recovery codes"
|
||||
>
|
||||
{Array.from(
|
||||
{ length: 8 },
|
||||
(_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 animate-pulse rounded bg-muted-foreground/20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground select-none">
|
||||
<p id="regenerate-warning">
|
||||
Each recovery code can be used once to
|
||||
access your account and will be removed
|
||||
after use. If you need more, click{' '}
|
||||
<span className="font-bold">
|
||||
Regenerate Codes
|
||||
</span>{' '}
|
||||
above.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
338
resources/js/components/two-factor-setup-modal.tsx
Normal file
338
resources/js/components/two-factor-setup-modal.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { useClipboard } from '@/hooks/use-clipboard';
|
||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
||||
import { confirm } from '@/routes/two-factor';
|
||||
import { Form } from '@inertiajs/react';
|
||||
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||
import { Check, Copy, Loader2, ScanLine } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import AlertError from './alert-error';
|
||||
|
||||
function GridScanIcon() {
|
||||
return (
|
||||
<div className="mb-3 rounded-full border border-border bg-card p-0.5 shadow-sm">
|
||||
<div className="relative overflow-hidden rounded-full border border-border bg-muted p-2.5">
|
||||
<div className="absolute inset-0 grid grid-cols-5 opacity-50">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div
|
||||
key={`col-${i + 1}`}
|
||||
className="border-r border-border last:border-r-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-0 grid grid-rows-5 opacity-50">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div
|
||||
key={`row-${i + 1}`}
|
||||
className="border-b border-border last:border-b-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScanLine className="relative z-20 size-6 text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TwoFactorSetupStep({
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
buttonText,
|
||||
onNextStep,
|
||||
errors,
|
||||
}: {
|
||||
qrCodeSvg: string | null;
|
||||
manualSetupKey: string | null;
|
||||
buttonText: string;
|
||||
onNextStep: () => void;
|
||||
errors: string[];
|
||||
}) {
|
||||
const [copiedText, copy] = useClipboard();
|
||||
const IconComponent = copiedText === manualSetupKey ? Check : Copy;
|
||||
|
||||
return (
|
||||
<>
|
||||
{errors?.length ? (
|
||||
<AlertError errors={errors} />
|
||||
) : (
|
||||
<>
|
||||
<div className="mx-auto flex max-w-md overflow-hidden">
|
||||
<div className="mx-auto aspect-square w-64 rounded-lg border border-border">
|
||||
<div className="z-10 flex h-full w-full items-center justify-center p-5">
|
||||
{qrCodeSvg ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: qrCodeSvg,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Loader2 className="flex size-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full space-x-5">
|
||||
<Button className="w-full" onClick={onNextStep}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full items-center justify-center">
|
||||
<div className="absolute inset-0 top-1/2 h-px w-full bg-border" />
|
||||
<span className="relative bg-card px-2 py-1">
|
||||
or, enter the code manually
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full space-x-2">
|
||||
<div className="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
|
||||
{!manualSetupKey ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted p-3">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={manualSetupKey}
|
||||
className="h-full w-full bg-background p-3 text-foreground outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copy(manualSetupKey)}
|
||||
className="border-l border-border px-3 hover:bg-muted"
|
||||
>
|
||||
<IconComponent className="w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TwoFactorVerificationStep({
|
||||
onClose,
|
||||
onBack,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [code, setCode] = useState<string>('');
|
||||
const pinInputContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
pinInputContainerRef.current?.querySelector('input')?.focus();
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...confirm.form()}
|
||||
onSuccess={() => onClose()}
|
||||
resetOnError
|
||||
resetOnSuccess
|
||||
>
|
||||
{({
|
||||
processing,
|
||||
errors,
|
||||
}: {
|
||||
processing: boolean;
|
||||
errors?: { confirmTwoFactorAuthentication?: { code?: string } };
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
ref={pinInputContainerRef}
|
||||
className="relative w-full space-y-3"
|
||||
>
|
||||
<div className="flex w-full flex-col items-center space-y-3 py-2">
|
||||
<InputOTP
|
||||
id="otp"
|
||||
name="code"
|
||||
maxLength={OTP_MAX_LENGTH}
|
||||
onChange={setCode}
|
||||
disabled={processing}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from(
|
||||
{ length: OTP_MAX_LENGTH },
|
||||
(_, index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<InputError
|
||||
message={
|
||||
errors?.confirmTwoFactorAuthentication?.code
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full space-x-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onBack}
|
||||
disabled={processing}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={
|
||||
processing || code.length < OTP_MAX_LENGTH
|
||||
}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface TwoFactorSetupModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
requiresConfirmation: boolean;
|
||||
twoFactorEnabled: boolean;
|
||||
qrCodeSvg: string | null;
|
||||
manualSetupKey: string | null;
|
||||
clearSetupData: () => void;
|
||||
fetchSetupData: () => Promise<void>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export default function TwoFactorSetupModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
requiresConfirmation,
|
||||
twoFactorEnabled,
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
clearSetupData,
|
||||
fetchSetupData,
|
||||
errors,
|
||||
}: TwoFactorSetupModalProps) {
|
||||
const [showVerificationStep, setShowVerificationStep] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const modalConfig = useMemo<{
|
||||
title: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
}>(() => {
|
||||
if (twoFactorEnabled) {
|
||||
return {
|
||||
title: 'Two-Factor Authentication Enabled',
|
||||
description:
|
||||
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
||||
buttonText: 'Close',
|
||||
};
|
||||
}
|
||||
|
||||
if (showVerificationStep) {
|
||||
return {
|
||||
title: 'Verify Authentication Code',
|
||||
description:
|
||||
'Enter the 6-digit code from your authenticator app',
|
||||
buttonText: 'Continue',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Enable Two-Factor Authentication',
|
||||
description:
|
||||
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
|
||||
buttonText: 'Continue',
|
||||
};
|
||||
}, [twoFactorEnabled, showVerificationStep]);
|
||||
|
||||
const handleModalNextStep = useCallback(() => {
|
||||
if (requiresConfirmation) {
|
||||
setShowVerificationStep(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
clearSetupData();
|
||||
onClose();
|
||||
}, [requiresConfirmation, clearSetupData, onClose]);
|
||||
|
||||
const resetModalState = useCallback(() => {
|
||||
setShowVerificationStep(false);
|
||||
if (twoFactorEnabled) {
|
||||
clearSetupData();
|
||||
}
|
||||
}, [twoFactorEnabled, clearSetupData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
resetModalState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!qrCodeSvg) {
|
||||
fetchSetupData();
|
||||
}
|
||||
}, [isOpen, qrCodeSvg, fetchSetupData, resetModalState]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="flex items-center justify-center">
|
||||
<GridScanIcon />
|
||||
<DialogTitle>{modalConfig.title}</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
{modalConfig.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center space-y-5">
|
||||
{showVerificationStep ? (
|
||||
<TwoFactorVerificationStep
|
||||
onClose={onClose}
|
||||
onBack={() => setShowVerificationStep(false)}
|
||||
/>
|
||||
) : (
|
||||
<TwoFactorSetupStep
|
||||
qrCodeSvg={qrCodeSvg}
|
||||
manualSetupKey={manualSetupKey}
|
||||
buttonText={modalConfig.buttonText}
|
||||
onNextStep={handleModalNextStep}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
64
resources/js/components/ui/accordion.tsx
Normal file
64
resources/js/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
66
resources/js/components/ui/alert.tsx
Normal file
66
resources/js/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
51
resources/js/components/ui/avatar.tsx
Normal file
51
resources/js/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
resources/js/components/ui/badge.tsx
Normal file
46
resources/js/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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 badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
109
resources/js/components/ui/breadcrumb.tsx
Normal file
109
resources/js/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
58
resources/js/components/ui/button.tsx
Normal file
58
resources/js/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
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-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs 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 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
68
resources/js/components/ui/card.tsx
Normal file
68
resources/js/components/ui/card.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col gap-1.5 px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
30
resources/js/components/ui/checkbox.tsx
Normal file
30
resources/js/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
31
resources/js/components/ui/collapsible.tsx
Normal file
31
resources/js/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
133
resources/js/components/ui/dialog.tsx
Normal file
133
resources/js/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
255
resources/js/components/ui/dropdown-menu.tsx
Normal file
255
resources/js/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
14
resources/js/components/ui/icon.tsx
Normal file
14
resources/js/components/ui/icon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface IconProps {
|
||||
iconNode?: LucideIcon | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Icon({ iconNode: IconComponent, className }: IconProps) {
|
||||
if (!IconComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <IconComponent className={className} />;
|
||||
}
|
||||
69
resources/js/components/ui/input-otp.tsx
Normal file
69
resources/js/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { Minus } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
InputOTP.displayName = "InputOTP"
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
))
|
||||
InputOTPGroup.displayName = "InputOTPGroup"
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-1 ring-ring",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
InputOTPSlot.displayName = "InputOTPSlot"
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
))
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
21
resources/js/components/ui/input.tsx
Normal file
21
resources/js/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
22
resources/js/components/ui/label.tsx
Normal file
22
resources/js/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
168
resources/js/components/ui/navigation-menu.tsx
Normal file
168
resources/js/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active=true]:bg-accent/50 data-[state=open]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
20
resources/js/components/ui/placeholder-pattern.tsx
Normal file
20
resources/js/components/ui/placeholder-pattern.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
interface PlaceholderPatternProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PlaceholderPattern({ className }: PlaceholderPatternProps) {
|
||||
const patternId = useId();
|
||||
|
||||
return (
|
||||
<svg className={className} fill="none">
|
||||
<defs>
|
||||
<pattern id={patternId} x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M-3 13 15-5M-5 5l18-18M-1 21 17 3"></path>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect stroke="none" fill={`url(#${patternId})`} width="100%" height="100%"></rect>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
179
resources/js/components/ui/select.tsx
Normal file
179
resources/js/components/ui/select.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
26
resources/js/components/ui/separator.tsx
Normal file
26
resources/js/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
137
resources/js/components/ui/sheet.tsx
Normal file
137
resources/js/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
721
resources/js/components/ui/sidebar.tsx
Normal file
721
resources/js/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,721 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { VariantProps, cva } from "class-variance-authority"
|
||||
import { PanelLeftIcon } 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,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} 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<SidebarContext | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
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)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; 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<SidebarContext>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-svh w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex max-w-full min-h-svh flex-1 flex-col",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 group-data-[collapsible=icon]:select-none group-data-[collapsible=icon]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
resources/js/components/ui/skeleton.tsx
Normal file
13
resources/js/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-primary/10 animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
23
resources/js/components/ui/sonner.tsx
Normal file
23
resources/js/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
114
resources/js/components/ui/table.tsx
Normal file
114
resources/js/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
64
resources/js/components/ui/tabs.tsx
Normal file
64
resources/js/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
18
resources/js/components/ui/textarea.tsx
Normal file
18
resources/js/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
71
resources/js/components/ui/toggle-group.tsx
Normal file
71
resources/js/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/toggle-group flex items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"min-w-0 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
45
resources/js/components/ui/toggle.tsx
Normal file
45
resources/js/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
59
resources/js/components/ui/tooltip.tsx
Normal file
59
resources/js/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 max-w-sm rounded-md px-3 py-1.5 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
32
resources/js/components/user-info.tsx
Normal file
32
resources/js/components/user-info.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { type User } from '@/types';
|
||||
|
||||
export function UserInfo({
|
||||
user,
|
||||
showEmail = false,
|
||||
}: {
|
||||
user: User;
|
||||
showEmail?: boolean;
|
||||
}) {
|
||||
const getInitials = useInitials();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Avatar className="h-8 w-8 overflow-hidden rounded-full">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
{showEmail && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
resources/js/components/user-menu-content.tsx
Normal file
64
resources/js/components/user-menu-content.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { UserInfo } from '@/components/user-info';
|
||||
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/profile';
|
||||
import { type User } from '@/types';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { LogOut, Settings } from 'lucide-react';
|
||||
|
||||
interface UserMenuContentProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function UserMenuContent({ user }: UserMenuContentProps) {
|
||||
const cleanup = useMobileNavigation();
|
||||
|
||||
const handleLogout = () => {
|
||||
cleanup();
|
||||
router.flushAll();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<UserInfo user={user} showEmail={true} />
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={edit()}
|
||||
as="button"
|
||||
prefetch
|
||||
onClick={cleanup}
|
||||
>
|
||||
<Settings className="mr-2" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={logout()}
|
||||
as="button"
|
||||
onClick={handleLogout}
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut className="mr-2" />
|
||||
Log out
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
resources/js/hooks/use-appearance.tsx
Normal file
82
resources/js/hooks/use-appearance.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export type Appearance = 'light' | 'dark' | 'system';
|
||||
|
||||
const prefersDark = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const setCookie = (name: string, value: string, days = 365) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
};
|
||||
|
||||
const applyTheme = (appearance: Appearance) => {
|
||||
const isDark =
|
||||
appearance === 'dark' || (appearance === 'system' && prefersDark());
|
||||
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const mediaQuery = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)');
|
||||
};
|
||||
|
||||
const handleSystemThemeChange = () => {
|
||||
const currentAppearance = localStorage.getItem('appearance') as Appearance;
|
||||
applyTheme(currentAppearance || 'system');
|
||||
};
|
||||
|
||||
export function initializeTheme() {
|
||||
const savedAppearance =
|
||||
(localStorage.getItem('appearance') as Appearance) || 'system';
|
||||
|
||||
applyTheme(savedAppearance);
|
||||
|
||||
// Add the event listener for system theme changes...
|
||||
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
|
||||
}
|
||||
|
||||
export function useAppearance() {
|
||||
const [appearance, setAppearance] = useState<Appearance>('system');
|
||||
|
||||
const updateAppearance = useCallback((mode: Appearance) => {
|
||||
setAppearance(mode);
|
||||
|
||||
// Store in localStorage for client-side persistence...
|
||||
localStorage.setItem('appearance', mode);
|
||||
|
||||
// Store in cookie for SSR...
|
||||
setCookie('appearance', mode);
|
||||
|
||||
applyTheme(mode);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const savedAppearance = localStorage.getItem(
|
||||
'appearance',
|
||||
) as Appearance | null;
|
||||
updateAppearance(savedAppearance || 'system');
|
||||
|
||||
return () =>
|
||||
mediaQuery()?.removeEventListener(
|
||||
'change',
|
||||
handleSystemThemeChange,
|
||||
);
|
||||
}, [updateAppearance]);
|
||||
|
||||
return { appearance, updateAppearance } as const;
|
||||
}
|
||||
32
resources/js/hooks/use-clipboard.ts
Normal file
32
resources/js/hooks/use-clipboard.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Credit: https://usehooks-ts.com/
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
type CopiedValue = string | null;
|
||||
|
||||
type CopyFn = (text: string) => Promise<boolean>;
|
||||
|
||||
export function useClipboard(): [CopiedValue, CopyFn] {
|
||||
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
|
||||
|
||||
const copy: CopyFn = useCallback(async (text) => {
|
||||
if (!navigator?.clipboard) {
|
||||
console.warn('Clipboard not supported');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedText(text);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Copy failed', error);
|
||||
setCopiedText(null);
|
||||
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [copiedText, copy];
|
||||
}
|
||||
15
resources/js/hooks/use-initials.tsx
Normal file
15
resources/js/hooks/use-initials.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useInitials() {
|
||||
return useCallback((fullName: string): string => {
|
||||
const names = fullName.trim().split(' ');
|
||||
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return names[0].charAt(0).toUpperCase();
|
||||
|
||||
const firstInitial = names[0].charAt(0);
|
||||
const lastInitial = names[names.length - 1].charAt(0);
|
||||
|
||||
return `${firstInitial}${lastInitial}`.toUpperCase();
|
||||
}, []);
|
||||
}
|
||||
8
resources/js/hooks/use-mobile-navigation.ts
Normal file
8
resources/js/hooks/use-mobile-navigation.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useMobileNavigation() {
|
||||
return useCallback(() => {
|
||||
// Remove pointer-events style from body...
|
||||
document.body.style.removeProperty('pointer-events');
|
||||
}, []);
|
||||
}
|
||||
24
resources/js/hooks/use-mobile.tsx
Normal file
24
resources/js/hooks/use-mobile.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(
|
||||
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
|
||||
);
|
||||
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
|
||||
mql.addEventListener('change', onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
104
resources/js/hooks/use-two-factor-auth.ts
Normal file
104
resources/js/hooks/use-two-factor-auth.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
interface TwoFactorSetupData {
|
||||
svg: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface TwoFactorSecretKey {
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
export const OTP_MAX_LENGTH = 6;
|
||||
|
||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const useTwoFactorAuth = () => {
|
||||
const [qrCodeSvg, setQrCodeSvg] = useState<string | null>(null);
|
||||
const [manualSetupKey, setManualSetupKey] = useState<string | null>(null);
|
||||
const [recoveryCodesList, setRecoveryCodesList] = useState<string[]>([]);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
const hasSetupData = useMemo<boolean>(
|
||||
() => qrCodeSvg !== null && manualSetupKey !== null,
|
||||
[qrCodeSvg, manualSetupKey],
|
||||
);
|
||||
|
||||
const fetchQrCode = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const { svg } = await fetchJson<TwoFactorSetupData>(qrCode.url());
|
||||
setQrCodeSvg(svg);
|
||||
} catch {
|
||||
setErrors((prev) => [...prev, 'Failed to fetch QR code']);
|
||||
setQrCodeSvg(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSetupKey = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const { secretKey: key } = await fetchJson<TwoFactorSecretKey>(
|
||||
secretKey.url(),
|
||||
);
|
||||
setManualSetupKey(key);
|
||||
} catch {
|
||||
setErrors((prev) => [...prev, 'Failed to fetch a setup key']);
|
||||
setManualSetupKey(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearErrors = useCallback((): void => {
|
||||
setErrors([]);
|
||||
}, []);
|
||||
|
||||
const clearSetupData = useCallback((): void => {
|
||||
setManualSetupKey(null);
|
||||
setQrCodeSvg(null);
|
||||
clearErrors();
|
||||
}, [clearErrors]);
|
||||
|
||||
const fetchRecoveryCodes = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
const codes = await fetchJson<string[]>(recoveryCodes.url());
|
||||
setRecoveryCodesList(codes);
|
||||
} catch {
|
||||
setErrors((prev) => [...prev, 'Failed to fetch recovery codes']);
|
||||
setRecoveryCodesList([]);
|
||||
}
|
||||
}, [clearErrors]);
|
||||
|
||||
const fetchSetupData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
||||
} catch {
|
||||
setQrCodeSvg(null);
|
||||
setManualSetupKey(null);
|
||||
}
|
||||
}, [clearErrors, fetchQrCode, fetchSetupKey]);
|
||||
|
||||
return {
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
recoveryCodesList,
|
||||
hasSetupData,
|
||||
errors,
|
||||
clearErrors,
|
||||
clearSetupData,
|
||||
fetchQrCode,
|
||||
fetchSetupKey,
|
||||
fetchSetupData,
|
||||
fetchRecoveryCodes,
|
||||
};
|
||||
};
|
||||
14
resources/js/layouts/app-layout.tsx
Normal file
14
resources/js/layouts/app-layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import AppLayoutTemplate from '@/layouts/app/app-sidebar-layout';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export default ({ children, breadcrumbs, ...props }: AppLayoutProps) => (
|
||||
<AppLayoutTemplate breadcrumbs={breadcrumbs} {...props}>
|
||||
{children}
|
||||
</AppLayoutTemplate>
|
||||
);
|
||||
17
resources/js/layouts/app/app-header-layout.tsx
Normal file
17
resources/js/layouts/app/app-header-layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AppContent } from '@/components/app-content';
|
||||
import { AppHeader } from '@/components/app-header';
|
||||
import { AppShell } from '@/components/app-shell';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
export default function AppHeaderLayout({
|
||||
children,
|
||||
breadcrumbs,
|
||||
}: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) {
|
||||
return (
|
||||
<AppShell>
|
||||
<AppHeader breadcrumbs={breadcrumbs} />
|
||||
<AppContent>{children}</AppContent>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
21
resources/js/layouts/app/app-sidebar-layout.tsx
Normal file
21
resources/js/layouts/app/app-sidebar-layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { AppContent } from '@/components/app-content';
|
||||
import { AppShell } from '@/components/app-shell';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { AppSidebarHeader } from '@/components/app-sidebar-header';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function AppSidebarLayout({
|
||||
children,
|
||||
breadcrumbs = [],
|
||||
}: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) {
|
||||
return (
|
||||
<AppShell variant="sidebar">
|
||||
<AppSidebar />
|
||||
<AppContent variant="sidebar" className="overflow-x-hidden">
|
||||
<AppSidebarHeader breadcrumbs={breadcrumbs} />
|
||||
{children}
|
||||
</AppContent>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
18
resources/js/layouts/auth-layout.tsx
Normal file
18
resources/js/layouts/auth-layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout';
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<AuthLayoutTemplate title={title} description={description} {...props}>
|
||||
{children}
|
||||
</AuthLayoutTemplate>
|
||||
);
|
||||
}
|
||||
48
resources/js/layouts/auth/auth-card-layout.tsx
Normal file
48
resources/js/layouts/auth/auth-card-layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { home } from '@/routes';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function AuthCardLayout({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
}: PropsWithChildren<{
|
||||
name?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Link
|
||||
href={home()}
|
||||
className="flex items-center gap-2 self-center font-medium"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center">
|
||||
<AppLogoIcon className="size-9 fill-current text-black dark:text-white" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card className="rounded-xl">
|
||||
<CardHeader className="px-10 pt-8 pb-0 text-center">
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-10 py-8">
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
resources/js/layouts/auth/auth-simple-layout.tsx
Normal file
44
resources/js/layouts/auth/auth-simple-layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { home } from '@/routes';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
name?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function AuthSimpleLayout({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
}: PropsWithChildren<AuthLayoutProps>) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Link
|
||||
href={home()}
|
||||
className="flex flex-col items-center gap-2 font-medium"
|
||||
>
|
||||
<div className="mb-1 flex h-9 w-9 items-center justify-center rounded-md">
|
||||
<AppLogoIcon className="size-9 fill-current text-[var(--foreground)] dark:text-white" />
|
||||
</div>
|
||||
<span className="sr-only">{title}</span>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
resources/js/layouts/auth/auth-split-layout.tsx
Normal file
62
resources/js/layouts/auth/auth-split-layout.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { home } from '@/routes';
|
||||
import { type SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function AuthSplitLayout({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
}: PropsWithChildren<AuthLayoutProps>) {
|
||||
const { name, quote } = usePage<SharedData>().props;
|
||||
|
||||
return (
|
||||
<div className="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
|
||||
<div className="absolute inset-0 bg-zinc-900" />
|
||||
<Link
|
||||
href={home()}
|
||||
className="relative z-20 flex items-center text-lg font-medium"
|
||||
>
|
||||
<AppLogoIcon className="mr-2 size-8 fill-current text-white" />
|
||||
{name}
|
||||
</Link>
|
||||
{quote && (
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg">
|
||||
“{quote.message}”
|
||||
</p>
|
||||
<footer className="text-sm text-neutral-300">
|
||||
{quote.author}
|
||||
</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full lg:p-8">
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<Link
|
||||
href={home()}
|
||||
className="relative z-20 flex items-center justify-center lg:hidden"
|
||||
>
|
||||
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
|
||||
</Link>
|
||||
<div className="flex flex-col items-start gap-2 text-left sm:items-center sm:text-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
<p className="text-sm text-balance text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
resources/js/layouts/client/layout.tsx
Normal file
12
resources/js/layouts/client/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Footer from '@/components/layout/Footer';
|
||||
import Navbar from '@/components/layout/Navbar';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto grid min-h-[100dvh] grid-rows-[auto_1fr_auto]">
|
||||
<Navbar />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
resources/js/layouts/settings/layout.tsx
Normal file
89
resources/js/layouts/settings/layout.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Heading from '@/components/heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { edit as editPassword } from '@/routes/password';
|
||||
import { edit } from '@/routes/profile';
|
||||
import { show } from '@/routes/two-factor';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
const sidebarNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Profile',
|
||||
href: edit(),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
title: 'Password',
|
||||
href: editPassword(),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
title: 'Two-Factor Auth',
|
||||
href: show(),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
href: editAppearance(),
|
||||
icon: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsLayout({ children }: PropsWithChildren) {
|
||||
// When server-side rendering, we only render the layout on the client...
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6">
|
||||
<Heading
|
||||
title="Settings"
|
||||
description="Manage your profile and account settings"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:space-x-12">
|
||||
<aside className="w-full max-w-xl lg:w-48">
|
||||
<nav className="flex flex-col space-y-1 space-x-0">
|
||||
{sidebarNavItems.map((item, index) => (
|
||||
<Button
|
||||
key={`${typeof item.href === 'string' ? item.href : item.href.url}-${index}`}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
asChild
|
||||
className={cn('w-full justify-start', {
|
||||
'bg-muted':
|
||||
currentPath ===
|
||||
(typeof item.href === 'string'
|
||||
? item.href
|
||||
: item.href.url),
|
||||
})}
|
||||
>
|
||||
<Link href={item.href}>
|
||||
{item.icon && (
|
||||
<item.icon className="h-4 w-4" />
|
||||
)}
|
||||
{item.title}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<Separator className="my-6 lg:hidden" />
|
||||
|
||||
<div className="flex-1 md:max-w-2xl">
|
||||
<section className="max-w-xl space-y-12">
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
resources/js/lib/utils.ts
Normal file
6
resources/js/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
170
resources/js/pages/about.tsx
Normal file
170
resources/js/pages/about.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import Layout from '@/layouts/client/layout';
|
||||
import ourStory from '@asset/img/about/A1PG1ALsIBL._AC_UF894,1000_QL80_.jpg';
|
||||
import kaleen from '@asset/img/about/kaleen.jpg';
|
||||
import ns from '@asset/img/about/ns.gif';
|
||||
import oko from '@asset/img/about/oko.gif';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
import { HeartHandshake, Lightbulb, Target } from 'lucide-react';
|
||||
export default function About() {
|
||||
return (
|
||||
<>
|
||||
<Head title="About Us">
|
||||
<meta
|
||||
name="description"
|
||||
content="Experience the finest hand-knotted carpets in Nepal with Soorya Carpet. Each piece is crafted by skilled artisans to deliver unmatched quality, luxury, and timeless design."
|
||||
/>
|
||||
</Head>
|
||||
<Layout>
|
||||
<section className="mx-auto flex max-w-screen-2xl flex-col gap-32 px-12 py-12 max-sm:px-8">
|
||||
<div className="grid h-1/2 grid-cols-2 gap-20 overflow-clip max-md:grid-cols-1 max-md:gap-12">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="font-serif text-5xl leading-tight font-medium tracking-tight max-sm:text-4xl">
|
||||
Our Story: Crafting Legacies Since 1974
|
||||
</h1>
|
||||
<div className="flex flex-col gap-4 text-sm max-sm:text-base">
|
||||
<p>
|
||||
The story of Soorya Carpet Industries begins
|
||||
in the early 1970s, when Mr. Laxman Shrestha
|
||||
and Mrs. Renuka Shrestha left their village
|
||||
in Dolakha and moved to Kathmandu with the
|
||||
ambition of building something meaningful.
|
||||
What started as a small rug-weaving
|
||||
initiative in a single room eventually grew
|
||||
into one of Nepal’s largest carpet
|
||||
manufacturing enterprises. Through
|
||||
perseverance and vision, they provided
|
||||
livelihoods to more than 1,000 skilled and
|
||||
unskilled workers and made a significant
|
||||
contribution to the country’s GDP. For over
|
||||
two decades, Soorya Carpet Industries earned
|
||||
international recognition, particularly in
|
||||
Germany, for exporting high-quality,
|
||||
hand-knotted carpets.
|
||||
</p>
|
||||
<p>
|
||||
By the late 1990s, however, Nepal’s carpet
|
||||
industry suffered severe setbacks owing to
|
||||
economic, social, and political challenges.
|
||||
Global scrutiny following child labour
|
||||
controversies in the wider industry, coupled
|
||||
with the instability caused by the Maoist
|
||||
insurgency, crippled the sector. Like many
|
||||
others, Soorya Carpet Industries was forced
|
||||
to scale down and eventually suspend
|
||||
production.
|
||||
</p>
|
||||
<p>
|
||||
The industry never quite returned to its
|
||||
early-1990s mass-scale export peak, yet it
|
||||
found ways to reinvent itself and stay
|
||||
relevant. Recognizing this shift, Soorya
|
||||
Carpet Industries transitioned from
|
||||
large-scale production to a boutique model,
|
||||
focusing on the niche luxury segment with
|
||||
custom-made, limited-edition rugs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
src={ourStory}
|
||||
alt="Our Story"
|
||||
className="aspect-[5/3] h-full w-full rounded-xl object-cover object-center"
|
||||
/>
|
||||
</div>{' '}
|
||||
<div className="grid h-1/2 grid-cols-2 gap-20 overflow-clip max-md:grid-cols-1 max-md:gap-12">
|
||||
<img
|
||||
src={kaleen}
|
||||
alt="Kaleen Story"
|
||||
className="aspect-[5/3] h-full w-full rounded-xl object-cover object-center"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-serif text-5xl leading-tight font-medium tracking-tight max-sm:text-4xl">
|
||||
The Kaleen Rebrand Story: A New Chapter
|
||||
</h2>
|
||||
<div className="flex flex-col gap-4 text-sm max-sm:text-base">
|
||||
<p>
|
||||
Guided by the aspiration to create premium,
|
||||
ethical and artistic carpets, this journey
|
||||
led to the launch of Kaleen—our exclusive
|
||||
designer carpet brand.
|
||||
</p>
|
||||
<p>
|
||||
Kaleen celebrates craftsmanship, artistry,
|
||||
and ethics. We create bespoke,
|
||||
limited-edition rugs that range from
|
||||
time-honoured traditional designs to bold,
|
||||
innovative forms, all crafted to complement
|
||||
modern interiors and aesthetics. Our
|
||||
collections are inspired by beautiful
|
||||
landscapes of Nepal and the five elements of
|
||||
life: Fire, Water, Earth, Air, and Metal.
|
||||
</p>
|
||||
<p>
|
||||
Handmade carpets have long been central to
|
||||
interior design—a tradition that remains
|
||||
timeless. At Kaleen, we are proud to carry
|
||||
this heritage forward with integrity and
|
||||
creativity. Our ambition is to build a
|
||||
strong brand identity, uphold ethical
|
||||
credibility, and expand into discerning
|
||||
global markets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-12 py-12 max-md:px-6">
|
||||
<h3 className="text-center font-serif text-5xl leading-tight font-medium tracking-tight max-sm:text-4xl">
|
||||
Our Guiding Principles
|
||||
</h3>
|
||||
<div className="flex gap-8 max-md:flex-col">
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl bg-accent px-8 py-8 text-center max-sm:px-4">
|
||||
<Lightbulb size={48} color="#5A1A1A" />
|
||||
<p className="font-serif text-3xl font-semibold">
|
||||
Our vision
|
||||
</p>
|
||||
<p className="text-center">
|
||||
To be the global leader in luxury handcrafted
|
||||
carpets, renowned for unparalleled quality,
|
||||
innovative design, and sustainable practices.
|
||||
</p>
|
||||
</div>{' '}
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl bg-accent px-8 py-8 text-center">
|
||||
<Target size={48} color="#5A1A1A" />
|
||||
<p className="font-serif text-3xl font-semibold">
|
||||
Our Mission
|
||||
</p>
|
||||
<p className="text-center">
|
||||
To meticulously craft exquisite carpets that
|
||||
enrich lives and spaces, preserving artisanal
|
||||
heritage while inspiring contemporary design.
|
||||
</p>
|
||||
</div>{' '}
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl bg-accent px-8 py-8 text-center">
|
||||
<HeartHandshake size={48} color="#5A1A1A" />
|
||||
<p className="font-serif text-3xl font-semibold">
|
||||
Our Promise
|
||||
</p>
|
||||
<p className="text-center">
|
||||
We promise exceptional artistry, ethical
|
||||
sourcing, and personalized service, ensuring
|
||||
every Kaleen carpet is a timeless masterpiece.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-12 py-12">
|
||||
<h4 className="text-center font-serif text-5xl leading-tight font-medium tracking-tight max-sm:text-4xl">
|
||||
Our Commitment to Quality & Ethics
|
||||
</h4>
|
||||
<div className="flex items-center justify-center gap-8">
|
||||
<img src={ns} alt="NS Standard" />
|
||||
<img src={oko} alt="OKO Standard" />
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
190
resources/js/pages/art.tsx
Normal file
190
resources/js/pages/art.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import Layout from '@/layouts/client/layout';
|
||||
import artOfWeaving from '@asset/img/art/IMG_4715.jpg';
|
||||
import skill from '@asset/img/material/silk balls.jpg';
|
||||
import skillYarn from '@asset/img/material/silk yarn.jpg';
|
||||
import woolBall from '@asset/img/material/wool balls.jpg';
|
||||
import woolYarn from '@asset/img/material/wool yarn.jpg';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { Flower, Hand, Package, Palette, Shirt } from 'lucide-react';
|
||||
|
||||
export default function Art() {
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<Head title="Art of Weaving" />
|
||||
<section>
|
||||
<div className="relative">
|
||||
<div className="absolute h-full w-full bg-white/70"></div>
|
||||
<img
|
||||
src={artOfWeaving}
|
||||
alt="Art of Weaving"
|
||||
className="aspect-[16/4] object-cover object-top"
|
||||
/>
|
||||
<h1 className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 font-serif text-7xl font-medium tracking-tight">
|
||||
The Art of Weaving
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col gap-12 px-12 py-12">
|
||||
<h2 className="text-center font-serif text-4xl font-medium">
|
||||
Our Craft: From Fiber to Fine Rug
|
||||
</h2>
|
||||
<Tabs
|
||||
defaultValue="wool"
|
||||
className="grid grid-cols-4 gap-8"
|
||||
>
|
||||
<TabsList className="!h-auto w-full flex-col">
|
||||
<TabsTrigger
|
||||
value="wool"
|
||||
className="flex w-full gap-2 py-4"
|
||||
>
|
||||
<Shirt size={24} /> Wool Selection & Sorting
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="dyeing"
|
||||
className="w-full py-4"
|
||||
>
|
||||
<Palette /> Artisan Dyeing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="hand"
|
||||
className="w-full py-4"
|
||||
>
|
||||
<Hand /> Hand Weaving
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="finish"
|
||||
className="w-full py-4"
|
||||
>
|
||||
<Flower /> Finishing & Washing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="quality"
|
||||
className="w-full py-4"
|
||||
>
|
||||
<Package /> Quality Control & Packing
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="col-span-3">
|
||||
<TabsContent value="wool">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-serif text-2xl font-medium">
|
||||
Wool Selection & Sorting
|
||||
</p>
|
||||
<p>
|
||||
Careful selection of the finest wool
|
||||
fibers, meticulously sorted by
|
||||
quality and texture to ensure only
|
||||
the best make it into our carpets.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="dyeing">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-serif text-2xl font-medium">
|
||||
Artisan Dyeing
|
||||
</p>
|
||||
<p>
|
||||
Fibers are dyed using traditional
|
||||
methods and natural pigments,
|
||||
achieving rich, vibrant, and
|
||||
enduring colors that tell a story.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="hand">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-serif text-2xl font-medium">
|
||||
Hand Weaving
|
||||
</p>
|
||||
<p>
|
||||
Skilled artisans hand-weave each
|
||||
knot with precision and passion,
|
||||
creating intricate patterns and
|
||||
unparalleled durability.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="finish">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-serif text-2xl font-medium">
|
||||
Finishing & Washing
|
||||
</p>
|
||||
<p>
|
||||
Carpets undergo specialized washing,
|
||||
shearing, and meticulous
|
||||
hand-finishing to enhance their
|
||||
luster and softness.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="quality">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-serif text-2xl font-medium">
|
||||
Quality Control & Packing
|
||||
</p>
|
||||
<p>
|
||||
Each rug passes rigorous quality
|
||||
checks before being carefully packed
|
||||
for its journey to your home,
|
||||
ensuring perfection.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col gap-12 px-12 py-8">
|
||||
<h3 className="text-center font-serif text-4xl font-medium">
|
||||
Exquisite Materials
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img
|
||||
src={skill}
|
||||
alt="Skill ball"
|
||||
className="aspect-square rounded-lg object-cover object-center"
|
||||
/>
|
||||
<p className="font-serif text-lg font-medium">
|
||||
Skill Ball
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img
|
||||
src={skillYarn}
|
||||
alt="Skill yarn"
|
||||
className="aspect-square rounded-lg object-cover object-center"
|
||||
/>
|
||||
<p className="font-serif text-lg font-medium">
|
||||
Skill Yarn
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img
|
||||
src={woolBall}
|
||||
alt="wool ball"
|
||||
className="aspect-square rounded-lg object-cover object-center"
|
||||
/>
|
||||
<p className="font-serif text-lg font-medium">
|
||||
Wool Ball
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img
|
||||
src={woolYarn}
|
||||
alt="wool yarn"
|
||||
className="aspect-square rounded-lg object-cover object-center"
|
||||
/>
|
||||
<p className="font-serif text-lg font-medium">
|
||||
Wool Yarn
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
resources/js/pages/auth/confirm-password.tsx
Normal file
52
resources/js/pages/auth/confirm-password.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import { store } from '@/routes/password/confirm';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
|
||||
export default function ConfirmPassword() {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Confirm your password"
|
||||
description="This is a secure area of the application. Please confirm your password before continuing."
|
||||
>
|
||||
<Head title="Confirm password" />
|
||||
|
||||
<Form {...store.form()} resetOnSuccess={['password']}>
|
||||
{({ processing, errors }) => (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={processing}
|
||||
data-test="confirm-password-button"
|
||||
>
|
||||
{processing && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Confirm password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
69
resources/js/pages/auth/forgot-password.tsx
Normal file
69
resources/js/pages/auth/forgot-password.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// Components
|
||||
import PasswordResetLinkController from '@/actions/App/Http/Controllers/Auth/PasswordResetLinkController';
|
||||
import { login } from '@/routes';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
export default function ForgotPassword({ status }: { status?: string }) {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Forgot password"
|
||||
description="Enter your email to receive a password reset link"
|
||||
>
|
||||
<Head title="Forgot password" />
|
||||
|
||||
{status && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<Form {...PasswordResetLinkController.store.form()}>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="my-6 flex items-center justify-start">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={processing}
|
||||
data-test="email-password-reset-link-button"
|
||||
>
|
||||
{processing && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Email password reset link
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
||||
<span>Or, return to</span>
|
||||
<TextLink href={login()}>log in</TextLink>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
115
resources/js/pages/auth/login.tsx
Normal file
115
resources/js/pages/auth/login.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import AuthenticatedSessionController from '@/actions/App/Http/Controllers/Auth/AuthenticatedSessionController';
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import { register } from '@/routes';
|
||||
import { request } from '@/routes/password';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
|
||||
interface LoginProps {
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
}
|
||||
|
||||
export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Log in to your account"
|
||||
description="Enter your email and password below to log in"
|
||||
>
|
||||
<Head title="Log in" />
|
||||
|
||||
<Form
|
||||
{...AuthenticatedSessionController.store.form()}
|
||||
resetOnSuccess={['password']}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
{canResetPassword && (
|
||||
<TextLink
|
||||
href={request()}
|
||||
className="ml-auto text-sm"
|
||||
tabIndex={5}
|
||||
>
|
||||
Forgot password?
|
||||
</TextLink>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
name="remember"
|
||||
tabIndex={3}
|
||||
/>
|
||||
<Label htmlFor="remember">Remember me</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4 w-full"
|
||||
tabIndex={4}
|
||||
disabled={processing}
|
||||
data-test="login-button"
|
||||
>
|
||||
{processing && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Log in
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<TextLink href={register()} tabIndex={5}>
|
||||
Sign up
|
||||
</TextLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
{status && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
117
resources/js/pages/auth/register.tsx
Normal file
117
resources/js/pages/auth/register.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import RegisteredUserController from '@/actions/App/Http/Controllers/Auth/RegisteredUserController';
|
||||
import { login } from '@/routes';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
export default function Register() {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Create an account"
|
||||
description="Enter your details below to create your account"
|
||||
>
|
||||
<Head title="Register" />
|
||||
<Form
|
||||
{...RegisteredUserController.store.form()}
|
||||
resetOnSuccess={['password', 'password_confirmation']}
|
||||
disableWhileProcessing
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="name"
|
||||
name="name"
|
||||
placeholder="Full name"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="email"
|
||||
name="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
tabIndex={3}
|
||||
autoComplete="new-password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Confirm password
|
||||
</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
required
|
||||
tabIndex={4}
|
||||
autoComplete="new-password"
|
||||
name="password_confirmation"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.password_confirmation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-2 w-full"
|
||||
tabIndex={5}
|
||||
data-test="register-user-button"
|
||||
>
|
||||
{processing && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<TextLink href={login()} tabIndex={6}>
|
||||
Log in
|
||||
</TextLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
96
resources/js/pages/auth/reset-password.tsx
Normal file
96
resources/js/pages/auth/reset-password.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import NewPasswordController from '@/actions/App/Http/Controllers/Auth/NewPasswordController';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
interface ResetPasswordProps {
|
||||
token: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default function ResetPassword({ token, email }: ResetPasswordProps) {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Reset password"
|
||||
description="Please enter your new password below"
|
||||
>
|
||||
<Head title="Reset password" />
|
||||
|
||||
<Form
|
||||
{...NewPasswordController.store.form()}
|
||||
transform={(data) => ({ ...data, token, email })}
|
||||
resetOnSuccess={['password', 'password_confirmation']}
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
className="mt-1 block w-full"
|
||||
readOnly
|
||||
/>
|
||||
<InputError
|
||||
message={errors.email}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
className="mt-1 block w-full"
|
||||
autoFocus
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Confirm password
|
||||
</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
name="password_confirmation"
|
||||
autoComplete="new-password"
|
||||
className="mt-1 block w-full"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.password_confirmation}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4 w-full"
|
||||
disabled={processing}
|
||||
data-test="reset-password-button"
|
||||
>
|
||||
{processing && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Reset password
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
131
resources/js/pages/auth/two-factor-challenge.tsx
Normal file
131
resources/js/pages/auth/two-factor-challenge.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import { store } from '@/routes/two-factor/login';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
export default function TwoFactorChallenge() {
|
||||
const [showRecoveryInput, setShowRecoveryInput] = useState<boolean>(false);
|
||||
const [code, setCode] = useState<string>('');
|
||||
|
||||
const authConfigContent = useMemo<{
|
||||
title: string;
|
||||
description: string;
|
||||
toggleText: string;
|
||||
}>(() => {
|
||||
if (showRecoveryInput) {
|
||||
return {
|
||||
title: 'Recovery Code',
|
||||
description:
|
||||
'Please confirm access to your account by entering one of your emergency recovery codes.',
|
||||
toggleText: 'login using an authentication code',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Authentication Code',
|
||||
description:
|
||||
'Enter the authentication code provided by your authenticator application.',
|
||||
toggleText: 'login using a recovery code',
|
||||
};
|
||||
}, [showRecoveryInput]);
|
||||
|
||||
const toggleRecoveryMode = (clearErrors: () => void): void => {
|
||||
setShowRecoveryInput(!showRecoveryInput);
|
||||
clearErrors();
|
||||
setCode('');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title={authConfigContent.title}
|
||||
description={authConfigContent.description}
|
||||
>
|
||||
<Head title="Two-Factor Authentication" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<Form
|
||||
{...store.form()}
|
||||
className="space-y-4"
|
||||
resetOnError
|
||||
resetOnSuccess={!showRecoveryInput}
|
||||
>
|
||||
{({ errors, processing, clearErrors }) => (
|
||||
<>
|
||||
{showRecoveryInput ? (
|
||||
<>
|
||||
<Input
|
||||
name="recovery_code"
|
||||
type="text"
|
||||
placeholder="Enter recovery code"
|
||||
autoFocus={showRecoveryInput}
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
message={errors.recovery_code}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center space-y-3 text-center">
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<InputOTP
|
||||
name="code"
|
||||
maxLength={OTP_MAX_LENGTH}
|
||||
value={code}
|
||||
onChange={(value) => setCode(value)}
|
||||
disabled={processing}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from(
|
||||
{ length: OTP_MAX_LENGTH },
|
||||
(_, index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
<InputError message={errors.code} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={processing}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<span>or you can </span>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
onClick={() =>
|
||||
toggleRecoveryMode(clearErrors)
|
||||
}
|
||||
>
|
||||
{authConfigContent.toggleText}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
50
resources/js/pages/auth/verify-email.tsx
Normal file
50
resources/js/pages/auth/verify-email.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// Components
|
||||
import EmailVerificationNotificationController from '@/actions/App/Http/Controllers/Auth/EmailVerificationNotificationController';
|
||||
import { logout } from '@/routes';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
export default function VerifyEmail({ status }: { status?: string }) {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Verify email"
|
||||
description="Please verify your email address by clicking on the link we just emailed to you."
|
||||
>
|
||||
<Head title="Email verification" />
|
||||
|
||||
{status === 'verification-link-sent' && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
A new verification link has been sent to the email address
|
||||
you provided during registration.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form
|
||||
{...EmailVerificationNotificationController.store.form()}
|
||||
className="space-y-6 text-center"
|
||||
>
|
||||
{({ processing }) => (
|
||||
<>
|
||||
<Button disabled={processing} variant="secondary">
|
||||
{processing && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Resend verification email
|
||||
</Button>
|
||||
|
||||
<TextLink
|
||||
href={logout()}
|
||||
className="mx-auto block text-sm"
|
||||
>
|
||||
Log out
|
||||
</TextLink>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
186
resources/js/pages/contact.tsx
Normal file
186
resources/js/pages/contact.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import Layout from '@/layouts/client/layout';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { Mail, MapPin, Phone } from 'lucide-react';
|
||||
|
||||
export default function Contact() {
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<Head title="Contact Us" />
|
||||
<section className="mx-auto flex max-w-screen-2xl flex-col gap-12 px-12 py-12 max-md:px-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-center font-serif text-5xl font-medium tracking-tight max-md:text-3xl">
|
||||
Connect with Kaleen Carpets
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
We are here to assist you with any inquiries,
|
||||
bespoke design requests, or partnership
|
||||
opportunities. <br /> Reach out to our team through
|
||||
our offices or the contact form below.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-8">
|
||||
<h2 className="text-center font-serif text-3xl font-medium tracking-tight">
|
||||
Our Global Offices
|
||||
</h2>
|
||||
<div className="flex items-start justify-center gap-24 max-md:flex-col max-md:gap-12">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-serif text-xl font-medium tracking-tight">
|
||||
Soorya Carpet Industries
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Our global presence to serve you better.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-2">
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<MapPin size={16} /> G.P.O Box 1765 2
|
||||
Swet Binayak Marg Thapathali -11,
|
||||
Kathmandu, Nepal
|
||||
</li>{' '}
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<Phone size={16} /> [+977-1] 4780923,
|
||||
4783003
|
||||
</li>{' '}
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<Mail size={16} />{' '}
|
||||
sooryacarpets@gmail.com
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-serif text-xl font-medium tracking-tight">
|
||||
Kaleen Studio
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Our global presence to serve you better.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-2">
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<MapPin size={16} /> Kadel Chautari
|
||||
Prasuti Griha Marg Ward No. 11,
|
||||
Babarmahal Kathmandu, Nepal
|
||||
</li>{' '}
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<Phone size={16} /> 9802341880
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
href="https://maps.app.goo.gl/666dbJEfsVvjJ3fu9"
|
||||
target="_blank"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Button
|
||||
className="w-full cursor-pointer border border-primary text-primary ring-primary"
|
||||
variant={'outline'}
|
||||
>
|
||||
Visit Studio
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-8 py-12">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-serif text-3xl font-medium">
|
||||
Send us a Message
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Fill out the form below and we'll get back to
|
||||
you as soon as possible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form action={'/'} className="flex flex-col gap-2">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
placeholder="Full Name"
|
||||
id="name"
|
||||
name="name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
placeholder="your.email@example.com"
|
||||
id="email"
|
||||
name="email"
|
||||
/>
|
||||
</div>{' '}
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select an inquiry type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="problem">
|
||||
Problem in product
|
||||
</SelectItem>
|
||||
<SelectItem value="contact">
|
||||
Contacting
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
placeholder="Tell us how can we help you"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="my-4">
|
||||
Send Message
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h4 className="text-center font-serif text-3xl font-medium tracking-tight">
|
||||
Direct Engagement
|
||||
</h4>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Prefer to speak with us directly or send a quick
|
||||
email? <br /> Our team is ready to assist.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="tel:9802341880">
|
||||
<Button className="cursor-pointer">
|
||||
<Phone /> Call Us Now
|
||||
</Button>
|
||||
</a>
|
||||
<a href="mailto:info@soory.com">
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="cursor-pointer border border-primary text-primary ring-primary"
|
||||
>
|
||||
<Mail /> Email Our Team
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
resources/js/pages/dashboard.tsx
Normal file
36
resources/js/pages/dashboard.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { index } from '@/routes/dashboard';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: index().url,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Dashboard" />
|
||||
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
94
resources/js/pages/dashboard/carousel/add.tsx
Normal file
94
resources/js/pages/dashboard/carousel/add.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import FilePondPluginImageExifOrientation from 'filepond-plugin-image-exif-orientation';
|
||||
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
|
||||
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
|
||||
import 'filepond/dist/filepond.min.css';
|
||||
import { useState } from 'react';
|
||||
import { FilePond, registerPlugin } from 'react-filepond';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
registerPlugin(FilePondPluginImageExifOrientation, FilePondPluginImagePreview);
|
||||
|
||||
export default function CarouselAdd() {
|
||||
const [file, setFile] = useState<any | null>(null);
|
||||
|
||||
const { data, setData, post, processing, errors, wasSuccessful } = useForm({
|
||||
alt: '',
|
||||
image_url: null as File | null,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (file) {
|
||||
setData('image_url', file.file);
|
||||
}
|
||||
|
||||
post('/carousel', {
|
||||
forceFormData: true,
|
||||
});
|
||||
|
||||
if (wasSuccessful) {
|
||||
toast.success('Slider image successfully uploaded');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Add Carousel" />
|
||||
<section className="flex flex-col gap-8 px-8 py-8">
|
||||
<h1 className="text-lg font-semibold tracking-tight">
|
||||
Add Slider Image
|
||||
</h1>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
method="POST"
|
||||
encType="multipart/formdata"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
value={data.alt}
|
||||
onChange={(e) => setData('alt', e.target.value)}
|
||||
className="rounded border p-2"
|
||||
/>
|
||||
{errors.alt && (
|
||||
<span className="text-red-600">{errors.alt}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Label>Image</Label>
|
||||
<FilePond
|
||||
files={file ? [file] : []}
|
||||
onupdatefiles={(fileItems) => {
|
||||
setFile(fileItems[0] || null);
|
||||
}}
|
||||
allowMultiple={false}
|
||||
maxFiles={1}
|
||||
name="image_url" // Change this to match your backend expectation
|
||||
instantUpload={false}
|
||||
storeAsFile={true}
|
||||
labelIdle='Drag & Drop your image or <span class="filepond--label-action">Browse</span>'
|
||||
/>
|
||||
{errors.image_url && (
|
||||
<span className="text-red-600">
|
||||
{errors.image_url}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Uploading...' : 'Submit'}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
83
resources/js/pages/dashboard/carousel/carousel.tsx
Normal file
83
resources/js/pages/dashboard/carousel/carousel.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import dashboardcarousel from '@/routes/dashboard/carousel';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function Carousel({
|
||||
carousel,
|
||||
}: {
|
||||
carousel: [{ alt: string; image_url: string }];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<AppLayout
|
||||
breadcrumbs={[
|
||||
{ title: 'Carousel', href: dashboardcarousel.index().url },
|
||||
]}
|
||||
>
|
||||
<Head title="Carousel"></Head>
|
||||
<section className="flex flex-col gap-8 px-8 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Carousel
|
||||
</h1>
|
||||
<Link href={dashboardcarousel.add()}>
|
||||
<Button>Add Image</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">S.N.</TableHead>
|
||||
<TableHead className="w-48">Image</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Action
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{carousel.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="font-medium">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<img
|
||||
src={item.image_url}
|
||||
alt={item.alt}
|
||||
className="aspect-video w-24 rounded-md object-cover object-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.alt}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href="">
|
||||
<Pencil size={18} />
|
||||
</Link>
|
||||
<Link href="">
|
||||
<Trash2
|
||||
size={18}
|
||||
color={'#D2042D'}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
</AppLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
resources/js/pages/dashboard/testimonial.tsx
Normal file
17
resources/js/pages/dashboard/testimonial.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import testimonial from '@/routes/testimonial';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
export default function Testimonial() {
|
||||
return (
|
||||
<>
|
||||
<AppLayout
|
||||
breadcrumbs={[
|
||||
{ title: 'testimonial', href: testimonial.index().url },
|
||||
]}
|
||||
>
|
||||
<Head title={'Testimonials'}></Head>
|
||||
</AppLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
83
resources/js/pages/faq.tsx
Normal file
83
resources/js/pages/faq.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Layout from '@/layouts/client/layout';
|
||||
import { contact } from '@/routes';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
|
||||
export default function Faq() {
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<Head title="FAQs" />
|
||||
<section className="mx-auto flex max-w-screen-2xl gap-32 px-12 py-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="font-serif text-4xl font-medium tracking-tight">
|
||||
Frequently Asked Questions
|
||||
</h1>
|
||||
<p>Any question, answered right here</p>
|
||||
</div>
|
||||
<Link href={contact()}>
|
||||
<Button
|
||||
className="cursor-pointer border border-primary font-serif ring-primary"
|
||||
variant={'outline'}
|
||||
>
|
||||
Contact Now
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full"
|
||||
defaultValue="item-1"
|
||||
>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger className="w-full">
|
||||
Is it accessible?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It adheres to the WAI-ARIA design
|
||||
pattern.
|
||||
</AccordionContent>
|
||||
</AccordionItem>{' '}
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger className="w-full">
|
||||
Is it accessible?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It adheres to the WAI-ARIA design
|
||||
pattern.
|
||||
</AccordionContent>
|
||||
</AccordionItem>{' '}
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger className="w-full">
|
||||
Is it accessible?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It adheres to the WAI-ARIA design
|
||||
pattern.
|
||||
</AccordionContent>
|
||||
</AccordionItem>{' '}
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger className="w-full">
|
||||
Is it accessible?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It adheres to the WAI-ARIA design
|
||||
pattern.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
resources/js/pages/product.tsx
Normal file
30
resources/js/pages/product.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import Layout from '@/layouts/client/layout';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
export default function Product() {
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<Head title="Our Products" />
|
||||
<section className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-12 py-8">
|
||||
<h1 className="font-serif text-5xl font-medium tracking-tight">
|
||||
Our Exquisite Collections
|
||||
</h1>
|
||||
<Tabs defaultValue="account" className="w-[400px]">
|
||||
<TabsList>
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">
|
||||
Make changes to your account here.
|
||||
</TabsContent>
|
||||
<TabsContent value="password">
|
||||
Change your password here.
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
resources/js/pages/settings/appearance.tsx
Normal file
34
resources/js/pages/settings/appearance.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
import AppearanceTabs from '@/components/appearance-tabs';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Appearance settings',
|
||||
href: editAppearance().url,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Appearance() {
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Appearance settings" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall
|
||||
title="Appearance settings"
|
||||
description="Update your account's appearance settings"
|
||||
/>
|
||||
<AppearanceTabs />
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
146
resources/js/pages/settings/password.tsx
Normal file
146
resources/js/pages/settings/password.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
|
||||
import InputError from '@/components/input-error';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { edit } from '@/routes/password';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Password settings',
|
||||
href: edit().url,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Password() {
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const currentPasswordInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Password settings" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall
|
||||
title="Update password"
|
||||
description="Ensure your account is using a long, random password to stay secure"
|
||||
/>
|
||||
|
||||
<Form
|
||||
{...PasswordController.update.form()}
|
||||
options={{
|
||||
preserveScroll: true,
|
||||
}}
|
||||
resetOnError={[
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
]}
|
||||
resetOnSuccess
|
||||
onError={(errors) => {
|
||||
if (errors.password) {
|
||||
passwordInput.current?.focus();
|
||||
}
|
||||
|
||||
if (errors.current_password) {
|
||||
currentPasswordInput.current?.focus();
|
||||
}
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
{({ errors, processing, recentlySuccessful }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="current_password">
|
||||
Current password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="current_password"
|
||||
ref={currentPasswordInput}
|
||||
name="current_password"
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="current-password"
|
||||
placeholder="Current password"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
message={errors.current_password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">
|
||||
New password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
ref={passwordInput}
|
||||
name="password"
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Confirm password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
message={errors.password_confirmation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
disabled={processing}
|
||||
data-test="update-password-button"
|
||||
>
|
||||
Save password
|
||||
</Button>
|
||||
|
||||
<Transition
|
||||
show={recentlySuccessful}
|
||||
enter="transition ease-in-out"
|
||||
enterFrom="opacity-0"
|
||||
leave="transition ease-in-out"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Saved
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
148
resources/js/pages/settings/profile.tsx
Normal file
148
resources/js/pages/settings/profile.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import { send } from '@/routes/verification';
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||||
|
||||
import DeleteUser from '@/components/delete-user';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { edit } from '@/routes/profile';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Profile settings',
|
||||
href: edit().url,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Profile({
|
||||
mustVerifyEmail,
|
||||
status,
|
||||
}: {
|
||||
mustVerifyEmail: boolean;
|
||||
status?: string;
|
||||
}) {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Profile settings" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall
|
||||
title="Profile information"
|
||||
description="Update your name and email address"
|
||||
/>
|
||||
|
||||
<Form
|
||||
{...ProfileController.update.form()}
|
||||
options={{
|
||||
preserveScroll: true,
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
{({ processing, recentlySuccessful, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
|
||||
<Input
|
||||
id="name"
|
||||
className="mt-1 block w-full"
|
||||
defaultValue={auth.user.name}
|
||||
name="name"
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder="Full name"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
className="mt-2"
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
className="mt-1 block w-full"
|
||||
defaultValue={auth.user.email}
|
||||
name="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
className="mt-2"
|
||||
message={errors.email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mustVerifyEmail &&
|
||||
auth.user.email_verified_at === null && (
|
||||
<div>
|
||||
<p className="-mt-4 text-sm text-muted-foreground">
|
||||
Your email address is
|
||||
unverified.{' '}
|
||||
<Link
|
||||
href={send()}
|
||||
as="button"
|
||||
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
>
|
||||
Click here to resend the
|
||||
verification email.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{status ===
|
||||
'verification-link-sent' && (
|
||||
<div className="mt-2 text-sm font-medium text-green-600">
|
||||
A new verification link has
|
||||
been sent to your email
|
||||
address.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
disabled={processing}
|
||||
data-test="update-profile-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<Transition
|
||||
show={recentlySuccessful}
|
||||
enter="transition ease-in-out"
|
||||
enterFrom="opacity-0"
|
||||
leave="transition ease-in-out"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Saved
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<DeleteUser />
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
137
resources/js/pages/settings/two-factor.tsx
Normal file
137
resources/js/pages/settings/two-factor.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes';
|
||||
import TwoFactorSetupModal from '@/components/two-factor-setup-modal';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { disable, enable, show } from '@/routes/two-factor';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ShieldBan, ShieldCheck } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface TwoFactorProps {
|
||||
requiresConfirmation?: boolean;
|
||||
twoFactorEnabled?: boolean;
|
||||
}
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Two-Factor Authentication',
|
||||
href: show.url(),
|
||||
},
|
||||
];
|
||||
|
||||
export default function TwoFactor({
|
||||
requiresConfirmation = false,
|
||||
twoFactorEnabled = false,
|
||||
}: TwoFactorProps) {
|
||||
const {
|
||||
qrCodeSvg,
|
||||
hasSetupData,
|
||||
manualSetupKey,
|
||||
clearSetupData,
|
||||
fetchSetupData,
|
||||
recoveryCodesList,
|
||||
fetchRecoveryCodes,
|
||||
errors,
|
||||
} = useTwoFactorAuth();
|
||||
const [showSetupModal, setShowSetupModal] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Two-Factor Authentication" />
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall
|
||||
title="Two-Factor Authentication"
|
||||
description="Manage your two-factor authentication settings"
|
||||
/>
|
||||
{twoFactorEnabled ? (
|
||||
<div className="flex flex-col items-start justify-start space-y-4">
|
||||
<Badge variant="default">Enabled</Badge>
|
||||
<p className="text-muted-foreground">
|
||||
With two-factor authentication enabled, you will
|
||||
be prompted for a secure, random pin during
|
||||
login, which you can retrieve from the
|
||||
TOTP-supported application on your phone.
|
||||
</p>
|
||||
|
||||
<TwoFactorRecoveryCodes
|
||||
recoveryCodesList={recoveryCodesList}
|
||||
fetchRecoveryCodes={fetchRecoveryCodes}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
<div className="relative inline">
|
||||
<Form {...disable.form()}>
|
||||
{({ processing }) => (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
<ShieldBan /> Disable 2FA
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-start justify-start space-y-4">
|
||||
<Badge variant="destructive">Disabled</Badge>
|
||||
<p className="text-muted-foreground">
|
||||
When you enable two-factor authentication, you
|
||||
will be prompted for a secure pin during login.
|
||||
This pin can be retrieved from a TOTP-supported
|
||||
application on your phone.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
{hasSetupData ? (
|
||||
<Button
|
||||
onClick={() => setShowSetupModal(true)}
|
||||
>
|
||||
<ShieldCheck />
|
||||
Continue Setup
|
||||
</Button>
|
||||
) : (
|
||||
<Form
|
||||
{...enable.form()}
|
||||
onSuccess={() =>
|
||||
setShowSetupModal(true)
|
||||
}
|
||||
>
|
||||
{({ processing }) => (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
<ShieldCheck />
|
||||
Enable 2FA
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TwoFactorSetupModal
|
||||
isOpen={showSetupModal}
|
||||
onClose={() => setShowSetupModal(false)}
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
twoFactorEnabled={twoFactorEnabled}
|
||||
qrCodeSvg={qrCodeSvg}
|
||||
manualSetupKey={manualSetupKey}
|
||||
clearSetupData={clearSetupData}
|
||||
fetchSetupData={fetchSetupData}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
117
resources/js/pages/welcome.tsx
Normal file
117
resources/js/pages/welcome.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Swiper, Testimonial } from '@/components/pages/Home';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Layout from '@/layouts/client/layout';
|
||||
import { about } from '@/routes';
|
||||
import artofWeaving from '@asset/img/carpet/IMG_1301.jpg';
|
||||
import kaleenCarpet from '@asset/img/carpet/IMG_3401.jpg';
|
||||
import sooryaCarpet from '@asset/img/carpet/sci trad 31.jpg';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { MoveRight } from 'lucide-react';
|
||||
|
||||
export default function Welcome({
|
||||
data,
|
||||
}: {
|
||||
data: { carousel: [{ image_url: string; alt: string }] };
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Premium Hand Knotted Carpets in Nepal">
|
||||
<link rel="preconnect" href="https://fonts.bunny.net" />
|
||||
<link
|
||||
href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Experience the finest hand-knotted carpets in Nepal with Soorya Carpet. Each piece is crafted by skilled artisans to deliver unmatched quality, luxury, and timeless design."
|
||||
/>
|
||||
</Head>
|
||||
<Layout>
|
||||
<div>
|
||||
<Swiper item={data?.carousel} />
|
||||
<section className="mx-auto grid max-w-screen-2xl grid-cols-2 gap-20 px-16 py-12">
|
||||
<img
|
||||
src={sooryaCarpet}
|
||||
alt="Soorya Carpet"
|
||||
className="aspect-video rounded-lg object-cover object-center"
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-serif text-3xl font-medium">
|
||||
Soorya Carpet
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
Discover the journey of Kaleen Carpets, a
|
||||
testament to enduring craftsmanship,
|
||||
innovative design, and a commitment to
|
||||
enriching lives through beautiful artistry.
|
||||
</p>
|
||||
</div>
|
||||
<Link href={about()}>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="w-fit cursor-pointer border border-primary bg-transparent font-serif ring-primary"
|
||||
>
|
||||
Learn More <MoveRight />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mx-auto grid max-w-screen-2xl grid-cols-2 gap-20 px-16 py-12">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-serif text-3xl font-medium">
|
||||
The Kaleen Rebrand
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
In 2015, Kaleen embarked on a transformative
|
||||
journey, evolving from a traditional
|
||||
manufacturer to a dynamic, luxury lifestyle
|
||||
brand.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="w-fit border border-primary bg-transparent font-serif ring-primary"
|
||||
>
|
||||
Learn More <MoveRight />
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
src={kaleenCarpet}
|
||||
alt="Kaleen Carpet"
|
||||
className="aspect-video rounded-lg object-cover object-center"
|
||||
/>
|
||||
</section>
|
||||
<section className="mx-auto grid max-w-screen-2xl grid-cols-2 gap-20 px-16 py-12">
|
||||
<img
|
||||
src={artofWeaving}
|
||||
alt="Art of Weaving"
|
||||
className="aspect-video rounded-lg object-cover object-center"
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-serif text-3xl font-medium">
|
||||
Art of Weaving
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
Since time immemorial, the art of carpet
|
||||
making has captured millions of carpet
|
||||
connoisseurs from around the world and still
|
||||
continues to do so.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="w-fit border border-primary bg-transparent font-serif ring-primary"
|
||||
>
|
||||
Learn More <MoveRight />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<Testimonial />
|
||||
</div>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
resources/js/ssr.tsx
Normal file
22
resources/js/ssr.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createInertiaApp } from '@inertiajs/react';
|
||||
import createServer from '@inertiajs/react/server';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
createServer((page) =>
|
||||
createInertiaApp({
|
||||
page,
|
||||
render: ReactDOMServer.renderToString,
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
resolve: (name) =>
|
||||
resolvePageComponent(
|
||||
`./pages/${name}.tsx`,
|
||||
import.meta.glob('./pages/**/*.tsx'),
|
||||
),
|
||||
setup: ({ App, props }) => {
|
||||
return <App {...props} />;
|
||||
},
|
||||
}),
|
||||
);
|
||||
43
resources/js/types/index.d.ts
vendored
Normal file
43
resources/js/types/index.d.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
import { InertiaLinkProps } from '@inertiajs/react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface Auth {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
title: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
href: NonNullable<InertiaLinkProps['href']>;
|
||||
icon?: LucideIcon | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface SharedData {
|
||||
name: string;
|
||||
quote: { message: string; author: string };
|
||||
auth: Auth;
|
||||
sidebarOpen: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
email_verified_at: string | null;
|
||||
two_factor_enabled?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
[key: string]: unknown; // This allows for additional properties...
|
||||
}
|
||||
1
resources/js/types/vite-env.d.ts
vendored
Normal file
1
resources/js/types/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user