img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/resources/js/components/ui/checkbox.tsx b/resources/js/components/ui/checkbox.tsx
new file mode 100644
index 0000000..6d1d6be
--- /dev/null
+++ b/resources/js/components/ui/checkbox.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import * as React from "react"
+import { Checkbox as CheckboxPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { CheckIcon } from "lucide-react"
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/resources/js/components/ui/collapsible.tsx b/resources/js/components/ui/collapsible.tsx
new file mode 100644
index 0000000..63fc8ef
--- /dev/null
+++ b/resources/js/components/ui/collapsible.tsx
@@ -0,0 +1,31 @@
+import { Collapsible as CollapsiblePrimitive } from "radix-ui"
+
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/resources/js/components/ui/dialog.tsx b/resources/js/components/ui/dialog.tsx
new file mode 100644
index 0000000..d9aecca
--- /dev/null
+++ b/resources/js/components/ui/dialog.tsx
@@ -0,0 +1,168 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/resources/js/components/ui/dropdown-menu.tsx b/resources/js/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..c263ad5
--- /dev/null
+++ b/resources/js/components/ui/dropdown-menu.tsx
@@ -0,0 +1,269 @@
+"use client"
+
+import * as React from "react"
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { CheckIcon, ChevronRightIcon } from "lucide-react"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ align = "start",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/resources/js/components/ui/icon.tsx b/resources/js/components/ui/icon.tsx
new file mode 100644
index 0000000..b9bd3b2
--- /dev/null
+++ b/resources/js/components/ui/icon.tsx
@@ -0,0 +1,14 @@
+import type { LucideIcon } from 'lucide-react';
+
+interface IconProps {
+ iconNode?: LucideIcon | null;
+ className?: string;
+}
+
+export function Icon({ iconNode: IconComponent, className }: IconProps) {
+ if (!IconComponent) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/resources/js/components/ui/input-otp.tsx b/resources/js/components/ui/input-otp.tsx
new file mode 100644
index 0000000..27c9ed6
--- /dev/null
+++ b/resources/js/components/ui/input-otp.tsx
@@ -0,0 +1,85 @@
+import * as React from "react"
+import { OTPInput, OTPInputContext } from "input-otp"
+
+import { cn } from "@/lib/utils"
+import { MinusIcon } from "lucide-react"
+
+function InputOTP({
+ className,
+ containerClassName,
+ ...props
+}: React.ComponentProps & {
+ containerClassName?: string
+}) {
+ return (
+
+ )
+}
+
+function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function InputOTPSlot({
+ index,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ index: number
+}) {
+ const inputOTPContext = React.useContext(OTPInputContext)
+ const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
+
+ return (
+
+ {char}
+ {hasFakeCaret && (
+
+ )}
+
+ )
+}
+
+function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
+ return (
+
+
+
+ )
+}
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
diff --git a/resources/js/components/ui/input.tsx b/resources/js/components/ui/input.tsx
new file mode 100644
index 0000000..d763cd9
--- /dev/null
+++ b/resources/js/components/ui/input.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/resources/js/components/ui/label.tsx b/resources/js/components/ui/label.tsx
new file mode 100644
index 0000000..1ac80f7
--- /dev/null
+++ b/resources/js/components/ui/label.tsx
@@ -0,0 +1,24 @@
+"use client"
+
+import * as React from "react"
+import { Label as LabelPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/resources/js/components/ui/navigation-menu.tsx b/resources/js/components/ui/navigation-menu.tsx
new file mode 100644
index 0000000..09596b1
--- /dev/null
+++ b/resources/js/components/ui/navigation-menu.tsx
@@ -0,0 +1,164 @@
+import * as React from "react"
+import { cva } from "class-variance-authority"
+import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon } from "lucide-react"
+
+function NavigationMenu({
+ className,
+ children,
+ viewport = true,
+ ...props
+}: React.ComponentProps & {
+ viewport?: boolean
+}) {
+ return (
+
+ {children}
+ {viewport && }
+
+ )
+}
+
+function NavigationMenuList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function NavigationMenuItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const navigationMenuTriggerStyle = cva(
+ "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
+)
+
+function NavigationMenuTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}{" "}
+
+
+ )
+}
+
+function NavigationMenuContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function NavigationMenuViewport({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function NavigationMenuLink({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function NavigationMenuIndicator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ NavigationMenu,
+ NavigationMenuList,
+ NavigationMenuItem,
+ NavigationMenuContent,
+ NavigationMenuTrigger,
+ NavigationMenuLink,
+ NavigationMenuIndicator,
+ NavigationMenuViewport,
+ navigationMenuTriggerStyle,
+}
diff --git a/resources/js/components/ui/placeholder-pattern.tsx b/resources/js/components/ui/placeholder-pattern.tsx
new file mode 100644
index 0000000..09f7ea4
--- /dev/null
+++ b/resources/js/components/ui/placeholder-pattern.tsx
@@ -0,0 +1,20 @@
+import { useId } from 'react';
+
+interface PlaceholderPatternProps {
+ className?: string;
+}
+
+export function PlaceholderPattern({ className }: PlaceholderPatternProps) {
+ const patternId = useId();
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/components/ui/select.tsx b/resources/js/components/ui/select.tsx
new file mode 100644
index 0000000..8333850
--- /dev/null
+++ b/resources/js/components/ui/select.tsx
@@ -0,0 +1,190 @@
+import * as React from "react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/resources/js/components/ui/separator.tsx b/resources/js/components/ui/separator.tsx
new file mode 100644
index 0000000..d457090
--- /dev/null
+++ b/resources/js/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/resources/js/components/ui/sheet.tsx b/resources/js/components/ui/sheet.tsx
new file mode 100644
index 0000000..49a6af2
--- /dev/null
+++ b/resources/js/components/ui/sheet.tsx
@@ -0,0 +1,145 @@
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/resources/js/components/ui/sidebar.tsx b/resources/js/components/ui/sidebar.tsx
new file mode 100644
index 0000000..1389fc5
--- /dev/null
+++ b/resources/js/components/ui/sidebar.tsx
@@ -0,0 +1,700 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+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,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { PanelLeftIcon } from "lucide-react"
+
+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 SidebarContextProps = {
+ state: "expanded" | "collapsed"
+ open: boolean
+ setOpen: (open: boolean) => void
+ openMobile: boolean
+ setOpenMobile: (open: boolean) => void
+ isMobile: boolean
+ toggleSidebar: () => void
+}
+
+const SidebarContext = React.createContext(null)
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext)
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.")
+ }
+
+ return context
+}
+
+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(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ dir,
+ ...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 (
+
+ {children}
+
+ )
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ )
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ )
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+ {
+ onClick?.(event)
+ toggleSidebar()
+ }}
+ {...props}
+ >
+
+ Toggle Sidebar
+
+ )
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ )
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroupLabel({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "div"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function SidebarGroupAction({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! 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 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_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
+} & VariantProps) {
+ const Comp = asChild ? Slot.Root : "button"
+ const { isMobile, state } = useSidebar()
+
+ const button = (
+
+ )
+
+ if (!tooltip) {
+ return button
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ }
+ }
+
+ return (
+
+ {button}
+
+
+ )
+}
+
+function SidebarMenuAction({
+ className,
+ asChild = false,
+ showOnHover = false,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean
+ showOnHover?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ showOnHover &&
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean
+}) {
+ // Random width between 50 to 90%.
+ const [width] = React.useState(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`
+ })
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ )
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubButton({
+ asChild = false,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
+ size?: "sm" | "md"
+ isActive?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "a"
+
+ return (
+ span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
+ 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,
+}
diff --git a/resources/js/components/ui/skeleton.tsx b/resources/js/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0118624
--- /dev/null
+++ b/resources/js/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/resources/js/components/ui/sonner.tsx b/resources/js/components/ui/sonner.tsx
new file mode 100644
index 0000000..9772eb2
--- /dev/null
+++ b/resources/js/components/ui/sonner.tsx
@@ -0,0 +1,47 @@
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ ),
+ info: (
+
+ ),
+ warning: (
+
+ ),
+ error: (
+
+ ),
+ loading: (
+
+ ),
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/resources/js/components/ui/spinner.tsx b/resources/js/components/ui/spinner.tsx
new file mode 100644
index 0000000..8dca22b
--- /dev/null
+++ b/resources/js/components/ui/spinner.tsx
@@ -0,0 +1,10 @@
+import { cn } from "@/lib/utils"
+import { Loader2Icon } from "lucide-react"
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+
+ )
+}
+
+export { Spinner }
diff --git a/resources/js/components/ui/toggle-group.tsx b/resources/js/components/ui/toggle-group.tsx
new file mode 100644
index 0000000..f797b05
--- /dev/null
+++ b/resources/js/components/ui/toggle-group.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import * as React from "react"
+import { type VariantProps } from "class-variance-authority"
+import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { toggleVariants } from "@/components/ui/toggle"
+
+const ToggleGroupContext = React.createContext<
+ VariantProps & {
+ spacing?: number
+ orientation?: "horizontal" | "vertical"
+ }
+>({
+ size: "default",
+ variant: "default",
+ spacing: 2,
+ orientation: "horizontal",
+})
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ spacing = 2,
+ orientation = "horizontal",
+ children,
+ ...props
+}: React.ComponentProps &
+ VariantProps & {
+ spacing?: number
+ orientation?: "horizontal" | "vertical"
+ }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant = "default",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ const context = React.useContext(ToggleGroupContext)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export { ToggleGroup, ToggleGroupItem }
diff --git a/resources/js/components/ui/toggle.tsx b/resources/js/components/ui/toggle.tsx
new file mode 100644
index 0000000..25b3acc
--- /dev/null
+++ b/resources/js/components/ui/toggle.tsx
@@ -0,0 +1,47 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Toggle as TogglePrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const toggleVariants = cva(
+ "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline: "border border-input bg-transparent hover:bg-muted",
+ },
+ size: {
+ default:
+ "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Toggle({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+export { Toggle, toggleVariants }
diff --git a/resources/js/components/ui/tooltip.tsx b/resources/js/components/ui/tooltip.tsx
new file mode 100644
index 0000000..7413f6e
--- /dev/null
+++ b/resources/js/components/ui/tooltip.tsx
@@ -0,0 +1,55 @@
+import * as React from "react"
+import { Tooltip as TooltipPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
diff --git a/resources/js/components/user-info.tsx b/resources/js/components/user-info.tsx
new file mode 100644
index 0000000..dd0848e
--- /dev/null
+++ b/resources/js/components/user-info.tsx
@@ -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 (
+ <>
+
+
+
+ {getInitials(user.name)}
+
+
+
+ {user.name}
+ {showEmail && (
+
+ {user.email}
+
+ )}
+
+ >
+ );
+}
diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx
new file mode 100644
index 0000000..adbda71
--- /dev/null
+++ b/resources/js/components/user-menu-content.tsx
@@ -0,0 +1,63 @@
+import { Link, router } from '@inertiajs/react';
+import { LogOut, Settings } from 'lucide-react';
+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';
+
+type Props = {
+ user: User;
+};
+
+export function UserMenuContent({ user }: Props) {
+ const cleanup = useMobileNavigation();
+
+ const handleLogout = () => {
+ cleanup();
+ router.flushAll();
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+ Log out
+
+
+ >
+ );
+}
diff --git a/resources/js/hooks/use-appearance.tsx b/resources/js/hooks/use-appearance.tsx
new file mode 100644
index 0000000..fa03f34
--- /dev/null
+++ b/resources/js/hooks/use-appearance.tsx
@@ -0,0 +1,115 @@
+import { useSyncExternalStore } from 'react';
+
+export type ResolvedAppearance = 'light' | 'dark';
+export type Appearance = ResolvedAppearance | 'system';
+
+export type UseAppearanceReturn = {
+ readonly appearance: Appearance;
+ readonly resolvedAppearance: ResolvedAppearance;
+ readonly updateAppearance: (mode: Appearance) => void;
+};
+
+const listeners = new Set<() => void>();
+let currentAppearance: Appearance = 'system';
+
+const prefersDark = (): boolean => {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
+};
+
+const setCookie = (name: string, value: string, days = 365): void => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ const maxAge = days * 24 * 60 * 60;
+ document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
+};
+
+const getStoredAppearance = (): Appearance => {
+ if (typeof window === 'undefined') {
+ return 'system';
+ }
+
+ return (localStorage.getItem('appearance') as Appearance) || 'system';
+};
+
+const isDarkMode = (appearance: Appearance): boolean => {
+ return appearance === 'dark' || (appearance === 'system' && prefersDark());
+};
+
+const applyTheme = (appearance: Appearance): void => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ const isDark = isDarkMode(appearance);
+
+ document.documentElement.classList.toggle('dark', isDark);
+ document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
+};
+
+const subscribe = (callback: () => void) => {
+ listeners.add(callback);
+
+ return () => listeners.delete(callback);
+};
+
+const notify = (): void => listeners.forEach((listener) => listener());
+
+const mediaQuery = (): MediaQueryList | null => {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ return window.matchMedia('(prefers-color-scheme: dark)');
+};
+
+const handleSystemThemeChange = (): void => applyTheme(currentAppearance);
+
+export function initializeTheme(): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ if (!localStorage.getItem('appearance')) {
+ localStorage.setItem('appearance', 'system');
+ setCookie('appearance', 'system');
+ }
+
+ currentAppearance = getStoredAppearance();
+ applyTheme(currentAppearance);
+
+ // Set up system theme change listener
+ mediaQuery()?.addEventListener('change', handleSystemThemeChange);
+}
+
+export function useAppearance(): UseAppearanceReturn {
+ const appearance: Appearance = useSyncExternalStore(
+ subscribe,
+ () => currentAppearance,
+ () => 'system',
+ );
+
+ const resolvedAppearance: ResolvedAppearance = isDarkMode(appearance)
+ ? 'dark'
+ : 'light';
+
+ const updateAppearance = (mode: Appearance): void => {
+ currentAppearance = mode;
+
+ // Store in localStorage for client-side persistence...
+ localStorage.setItem('appearance', mode);
+
+ // Store in cookie for SSR...
+ setCookie('appearance', mode);
+
+ applyTheme(mode);
+ notify();
+ };
+
+ return { appearance, resolvedAppearance, updateAppearance } as const;
+}
diff --git a/resources/js/hooks/use-clipboard.ts b/resources/js/hooks/use-clipboard.ts
new file mode 100644
index 0000000..03dada7
--- /dev/null
+++ b/resources/js/hooks/use-clipboard.ts
@@ -0,0 +1,32 @@
+// Credit: https://usehooks-ts.com/
+import { useState } from 'react';
+
+export type CopiedValue = string | null;
+export type CopyFn = (text: string) => Promise;
+export type UseClipboardReturn = [CopiedValue, CopyFn];
+
+export function useClipboard(): UseClipboardReturn {
+ const [copiedText, setCopiedText] = useState(null);
+
+ const copy: CopyFn = 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];
+}
diff --git a/resources/js/hooks/use-current-url.ts b/resources/js/hooks/use-current-url.ts
new file mode 100644
index 0000000..ee8cd2b
--- /dev/null
+++ b/resources/js/hooks/use-current-url.ts
@@ -0,0 +1,83 @@
+import type { InertiaLinkProps } from '@inertiajs/react';
+import { usePage } from '@inertiajs/react';
+import { toUrl } from '@/lib/utils';
+
+export type IsCurrentUrlFn = (
+ urlToCheck: NonNullable,
+ currentUrl?: string,
+ startsWith?: boolean,
+) => boolean;
+
+export type IsCurrentOrParentUrlFn = (
+ urlToCheck: NonNullable,
+ currentUrl?: string,
+) => boolean;
+
+export type WhenCurrentUrlFn = (
+ urlToCheck: NonNullable,
+ ifTrue: TIfTrue,
+ ifFalse?: TIfFalse,
+) => TIfTrue | TIfFalse;
+
+export type UseCurrentUrlReturn = {
+ currentUrl: string;
+ isCurrentUrl: IsCurrentUrlFn;
+ isCurrentOrParentUrl: IsCurrentOrParentUrlFn;
+ whenCurrentUrl: WhenCurrentUrlFn;
+};
+
+export function useCurrentUrl(): UseCurrentUrlReturn {
+ const page = usePage();
+ const currentUrlPath = new URL(
+ page.url,
+ typeof window !== 'undefined'
+ ? window.location.origin
+ : 'http://localhost',
+ ).pathname;
+
+ const isCurrentUrl: IsCurrentUrlFn = (
+ urlToCheck: NonNullable,
+ currentUrl?: string,
+ startsWith: boolean = false,
+ ) => {
+ const urlToCompare = currentUrl ?? currentUrlPath;
+ const urlString = toUrl(urlToCheck);
+
+ const comparePath = (path: string): boolean =>
+ startsWith ? urlToCompare.startsWith(path) : path === urlToCompare;
+
+ if (!urlString.startsWith('http')) {
+ return comparePath(urlString);
+ }
+
+ try {
+ const absoluteUrl = new URL(urlString);
+
+ return comparePath(absoluteUrl.pathname);
+ } catch {
+ return false;
+ }
+ };
+
+ const isCurrentOrParentUrl: IsCurrentOrParentUrlFn = (
+ urlToCheck: NonNullable,
+ currentUrl?: string,
+ ) => {
+ return isCurrentUrl(urlToCheck, currentUrl, true);
+ };
+
+ const whenCurrentUrl: WhenCurrentUrlFn = (
+ urlToCheck: NonNullable,
+ ifTrue: TIfTrue,
+ ifFalse: TIfFalse = null as TIfFalse,
+ ): TIfTrue | TIfFalse => {
+ return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
+ };
+
+ return {
+ currentUrl: currentUrlPath,
+ isCurrentUrl,
+ isCurrentOrParentUrl,
+ whenCurrentUrl,
+ };
+}
diff --git a/resources/js/hooks/use-flash-toast.ts b/resources/js/hooks/use-flash-toast.ts
new file mode 100644
index 0000000..755c210
--- /dev/null
+++ b/resources/js/hooks/use-flash-toast.ts
@@ -0,0 +1,19 @@
+import { router } from '@inertiajs/react';
+import { useEffect } from 'react';
+import { toast } from 'sonner';
+import type { FlashToast } from '@/types/ui';
+
+export function useFlashToast(): void {
+ useEffect(() => {
+ return router.on('flash', (event) => {
+ const flash = (event as CustomEvent).detail?.flash;
+ const data = flash?.toast as FlashToast | undefined;
+
+ if (!data) {
+ return;
+ }
+
+ toast[data.type](data.message);
+ });
+ }, []);
+}
diff --git a/resources/js/hooks/use-initials.tsx b/resources/js/hooks/use-initials.tsx
new file mode 100644
index 0000000..407309f
--- /dev/null
+++ b/resources/js/hooks/use-initials.tsx
@@ -0,0 +1,26 @@
+import { useCallback } from 'react';
+
+export type GetInitialsFn = (fullName: string) => string;
+
+function getInitial(name: string): string {
+ return Array.from(name)[0] ?? '';
+}
+
+export function useInitials(): GetInitialsFn {
+ return useCallback((fullName: string): string => {
+ const names = fullName.trim().split(/\s+/u).filter(Boolean);
+
+ if (names.length === 0) {
+ return '';
+ }
+
+ if (names.length === 1) {
+ return getInitial(names[0]).toUpperCase();
+ }
+
+ const firstInitial = getInitial(names[0]);
+ const lastInitial = getInitial(names[names.length - 1]);
+
+ return `${firstInitial}${lastInitial}`.toUpperCase();
+ }, []);
+}
diff --git a/resources/js/hooks/use-mobile-navigation.ts b/resources/js/hooks/use-mobile-navigation.ts
new file mode 100644
index 0000000..834b9c9
--- /dev/null
+++ b/resources/js/hooks/use-mobile-navigation.ts
@@ -0,0 +1,10 @@
+import { useCallback } from 'react';
+
+export type CleanupFn = () => void;
+
+export function useMobileNavigation(): CleanupFn {
+ return useCallback(() => {
+ // Remove pointer-events style from body...
+ document.body.style.removeProperty('pointer-events');
+ }, []);
+}
diff --git a/resources/js/hooks/use-mobile.ts b/resources/js/hooks/use-mobile.ts
new file mode 100644
index 0000000..2b0fe1d
--- /dev/null
+++ b/resources/js/hooks/use-mobile.ts
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(undefined)
+
+ React.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
+}
diff --git a/resources/js/hooks/use-mobile.tsx b/resources/js/hooks/use-mobile.tsx
new file mode 100644
index 0000000..3ff7d30
--- /dev/null
+++ b/resources/js/hooks/use-mobile.tsx
@@ -0,0 +1,36 @@
+import { useSyncExternalStore } from 'react';
+
+const MOBILE_BREAKPOINT = 768;
+
+const mql =
+ typeof window === 'undefined'
+ ? undefined
+ : window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+
+function mediaQueryListener(callback: (event: MediaQueryListEvent) => void) {
+ if (!mql) {
+ return () => {};
+ }
+
+ mql.addEventListener('change', callback);
+
+ return () => {
+ mql.removeEventListener('change', callback);
+ };
+}
+
+function isSmallerThanBreakpoint(): boolean {
+ return mql?.matches ?? false;
+}
+
+function getServerSnapshot(): boolean {
+ return false;
+}
+
+export function useIsMobile(): boolean {
+ return useSyncExternalStore(
+ mediaQueryListener,
+ isSmallerThanBreakpoint,
+ getServerSnapshot,
+ );
+}
diff --git a/resources/js/hooks/use-two-factor-auth.ts b/resources/js/hooks/use-two-factor-auth.ts
new file mode 100644
index 0000000..c89adcd
--- /dev/null
+++ b/resources/js/hooks/use-two-factor-auth.ts
@@ -0,0 +1,111 @@
+import { useHttp } from '@inertiajs/react';
+import { useCallback, useState } from 'react';
+import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
+
+export type UseTwoFactorAuthReturn = {
+ qrCodeSvg: string | null;
+ manualSetupKey: string | null;
+ recoveryCodesList: string[];
+ hasSetupData: boolean;
+ errors: string[];
+ clearErrors: () => void;
+ clearSetupData: () => void;
+ clearTwoFactorAuthData: () => void;
+ fetchQrCode: () => Promise;
+ fetchSetupKey: () => Promise;
+ fetchSetupData: () => Promise;
+ fetchRecoveryCodes: () => Promise;
+};
+
+export const OTP_MAX_LENGTH = 6;
+
+export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
+ const { submit } = useHttp();
+
+ const [qrCodeSvg, setQrCodeSvg] = useState(null);
+ const [manualSetupKey, setManualSetupKey] = useState(null);
+ const [recoveryCodesList, setRecoveryCodesList] = useState([]);
+ const [errors, setErrors] = useState([]);
+
+ const hasSetupData = qrCodeSvg !== null && manualSetupKey !== null;
+
+ const clearErrors = useCallback((): void => {
+ setErrors([]);
+ }, []);
+
+ const clearSetupData = useCallback((): void => {
+ setManualSetupKey(null);
+ setQrCodeSvg(null);
+ setErrors([]);
+ }, []);
+
+ const clearTwoFactorAuthData = useCallback((): void => {
+ setManualSetupKey(null);
+ setQrCodeSvg(null);
+ setErrors([]);
+ setRecoveryCodesList([]);
+ }, []);
+
+ const fetchQrCode = useCallback(async (): Promise => {
+ try {
+ const { svg } = (await submit(qrCode())) as {
+ svg: string;
+ url: string;
+ };
+
+ setQrCodeSvg(svg);
+ } catch {
+ setErrors((prev) => [...prev, 'Failed to fetch QR code']);
+ setQrCodeSvg(null);
+ }
+ }, [submit]);
+
+ const fetchSetupKey = useCallback(async (): Promise => {
+ try {
+ const { secretKey: key } = (await submit(secretKey())) as {
+ secretKey: string;
+ };
+
+ setManualSetupKey(key);
+ } catch {
+ setErrors((prev) => [...prev, 'Failed to fetch a setup key']);
+ setManualSetupKey(null);
+ }
+ }, [submit]);
+
+ const fetchRecoveryCodes = useCallback(async (): Promise => {
+ try {
+ setErrors([]);
+ const codes = (await submit(recoveryCodes())) as string[];
+ setRecoveryCodesList(codes);
+ } catch {
+ setErrors((prev) => [...prev, 'Failed to fetch recovery codes']);
+ setRecoveryCodesList([]);
+ }
+ }, [submit]);
+
+ const fetchSetupData = useCallback(async (): Promise => {
+ try {
+ setErrors([]);
+ await Promise.all([fetchQrCode(), fetchSetupKey()]);
+ } catch {
+ setQrCodeSvg(null);
+ setManualSetupKey(null);
+ }
+ }, [fetchQrCode, fetchSetupKey]);
+
+ return {
+ qrCodeSvg,
+ manualSetupKey,
+ recoveryCodesList,
+ hasSetupData,
+ errors,
+ clearErrors,
+ clearSetupData,
+ clearTwoFactorAuthData,
+ fetchQrCode,
+ fetchSetupKey,
+ fetchSetupData,
+ fetchRecoveryCodes,
+ };
+};
diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx
new file mode 100644
index 0000000..098b981
--- /dev/null
+++ b/resources/js/layouts/app-layout.tsx
@@ -0,0 +1,16 @@
+import AppLayoutTemplate from '@/layouts/app/app-sidebar-layout';
+import type { BreadcrumbItem } from '@/types';
+
+export default function AppLayout({
+ breadcrumbs = [],
+ children,
+}: {
+ breadcrumbs?: BreadcrumbItem[];
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/resources/js/layouts/app/app-header-layout.tsx b/resources/js/layouts/app/app-header-layout.tsx
new file mode 100644
index 0000000..227530b
--- /dev/null
+++ b/resources/js/layouts/app/app-header-layout.tsx
@@ -0,0 +1,16 @@
+import { AppContent } from '@/components/app-content';
+import { AppHeader } from '@/components/app-header';
+import { AppShell } from '@/components/app-shell';
+import type { AppLayoutProps } from '@/types';
+
+export default function AppHeaderLayout({
+ children,
+ breadcrumbs,
+}: AppLayoutProps) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/resources/js/layouts/app/app-sidebar-layout.tsx b/resources/js/layouts/app/app-sidebar-layout.tsx
new file mode 100644
index 0000000..a6edb62
--- /dev/null
+++ b/resources/js/layouts/app/app-sidebar-layout.tsx
@@ -0,0 +1,20 @@
+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 { AppLayoutProps } from '@/types';
+
+export default function AppSidebarLayout({
+ children,
+ breadcrumbs = [],
+}: AppLayoutProps) {
+ return (
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/resources/js/layouts/auth-layout.tsx b/resources/js/layouts/auth-layout.tsx
new file mode 100644
index 0000000..698dd75
--- /dev/null
+++ b/resources/js/layouts/auth-layout.tsx
@@ -0,0 +1,17 @@
+import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout';
+
+export default function AuthLayout({
+ title = '',
+ description = '',
+ children,
+}: {
+ title?: string;
+ description?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/resources/js/layouts/auth/auth-card-layout.tsx b/resources/js/layouts/auth/auth-card-layout.tsx
new file mode 100644
index 0000000..851ac5f
--- /dev/null
+++ b/resources/js/layouts/auth/auth-card-layout.tsx
@@ -0,0 +1,48 @@
+import { Link } from '@inertiajs/react';
+import type { PropsWithChildren } from 'react';
+import AppLogoIcon from '@/components/app-logo-icon';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { home } from '@/routes';
+
+export default function AuthCardLayout({
+ children,
+ title,
+ description,
+}: PropsWithChildren<{
+ name?: string;
+ title?: string;
+ description?: string;
+}>) {
+ return (
+
+
+
+
+
+
+
+
+
+ {title}
+ {description}
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/resources/js/layouts/auth/auth-simple-layout.tsx b/resources/js/layouts/auth/auth-simple-layout.tsx
new file mode 100644
index 0000000..b1396ae
--- /dev/null
+++ b/resources/js/layouts/auth/auth-simple-layout.tsx
@@ -0,0 +1,38 @@
+import { Link } from '@inertiajs/react';
+import AppLogoIcon from '@/components/app-logo-icon';
+import { home } from '@/routes';
+import type { AuthLayoutProps } from '@/types';
+
+export default function AuthSimpleLayout({
+ children,
+ title,
+ description,
+}: AuthLayoutProps) {
+ return (
+
+
+
+
+
+
+
{title}
+
+
+
+
{title}
+
+ {description}
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/resources/js/layouts/auth/auth-split-layout.tsx b/resources/js/layouts/auth/auth-split-layout.tsx
new file mode 100644
index 0000000..a567729
--- /dev/null
+++ b/resources/js/layouts/auth/auth-split-layout.tsx
@@ -0,0 +1,44 @@
+import { Link, usePage } from '@inertiajs/react';
+import AppLogoIcon from '@/components/app-logo-icon';
+import { home } from '@/routes';
+import type { AuthLayoutProps } from '@/types';
+
+export default function AuthSplitLayout({
+ children,
+ title,
+ description,
+}: AuthLayoutProps) {
+ const { name } = usePage().props;
+
+ return (
+
+
+
+
+
+
+
+
+
{title}
+
+ {description}
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx
new file mode 100644
index 0000000..efd7904
--- /dev/null
+++ b/resources/js/layouts/settings/layout.tsx
@@ -0,0 +1,78 @@
+import { Link } from '@inertiajs/react';
+import type { PropsWithChildren } from 'react';
+import Heading from '@/components/heading';
+import { Button } from '@/components/ui/button';
+import { Separator } from '@/components/ui/separator';
+import { useCurrentUrl } from '@/hooks/use-current-url';
+import { cn, toUrl } from '@/lib/utils';
+import { edit as editAppearance } from '@/routes/appearance';
+import { edit } from '@/routes/profile';
+import { edit as editSecurity } from '@/routes/security';
+import type { NavItem } from '@/types';
+
+const sidebarNavItems: NavItem[] = [
+ {
+ title: 'Profile',
+ href: edit(),
+ icon: null,
+ },
+ {
+ title: 'Security',
+ href: editSecurity(),
+ icon: null,
+ },
+ {
+ title: 'Appearance',
+ href: editAppearance(),
+ icon: null,
+ },
+];
+
+export default function SettingsLayout({ children }: PropsWithChildren) {
+ const { isCurrentOrParentUrl } = useCurrentUrl();
+
+ return (
+
+
+
+
+
+
+ {sidebarNavItems.map((item, index) => (
+
+
+ {item.icon && (
+
+ )}
+ {item.title}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts
new file mode 100644
index 0000000..3ead67f
--- /dev/null
+++ b/resources/js/lib/utils.ts
@@ -0,0 +1,12 @@
+import type { InertiaLinkProps } from '@inertiajs/react';
+import { clsx } from 'clsx';
+import type { ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function toUrl(url: NonNullable): string {
+ return typeof url === 'string' ? url : url.url;
+}
diff --git a/resources/js/pages/auth/confirm-password.tsx b/resources/js/pages/auth/confirm-password.tsx
new file mode 100644
index 0000000..0489ce8
--- /dev/null
+++ b/resources/js/pages/auth/confirm-password.tsx
@@ -0,0 +1,66 @@
+import { Form, Head } from '@inertiajs/react';
+import {
+ index as confirmOptions,
+ store as confirmStore,
+} from '@/actions/Laravel/Passkeys/Http/Controllers/PasskeyConfirmationController';
+import InputError from '@/components/input-error';
+import PasskeyVerify from '@/components/passkey-verify';
+import PasswordInput from '@/components/password-input';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Spinner } from '@/components/ui/spinner';
+import { store } from '@/routes/password/confirm';
+
+export default function ConfirmPassword() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+
+ConfirmPassword.layout = {
+ title: 'Confirm password',
+ description:
+ 'This is a secure area of the application. Please confirm your password before continuing.',
+};
diff --git a/resources/js/pages/auth/forgot-password.tsx b/resources/js/pages/auth/forgot-password.tsx
new file mode 100644
index 0000000..25c81b9
--- /dev/null
+++ b/resources/js/pages/auth/forgot-password.tsx
@@ -0,0 +1,69 @@
+// Components
+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 { login } from '@/routes';
+import { email } from '@/routes/password';
+
+export default function ForgotPassword({ status }: { status?: string }) {
+ return (
+ <>
+
+
+ {status && (
+
+ {status}
+
+ )}
+
+
+
+
+
+ Or, return to
+ log in
+
+
+ >
+ );
+}
+
+ForgotPassword.layout = {
+ title: 'Forgot password',
+ description: 'Enter your email to receive a password reset link',
+};
diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx
new file mode 100644
index 0000000..f5402ae
--- /dev/null
+++ b/resources/js/pages/auth/login.tsx
@@ -0,0 +1,117 @@
+import { Form, Head } from '@inertiajs/react';
+import InputError from '@/components/input-error';
+import PasskeyVerify from '@/components/passkey-verify';
+import PasswordInput from '@/components/password-input';
+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 { Spinner } from '@/components/ui/spinner';
+import { register } from '@/routes';
+import { store } from '@/routes/login';
+import { request } from '@/routes/password';
+
+type Props = {
+ status?: string;
+ canResetPassword: boolean;
+};
+
+export default function Login({ status, canResetPassword }: Props) {
+ return (
+ <>
+
+
+
+
+
+
+ {status && (
+
+ {status}
+
+ )}
+ >
+ );
+}
+
+Login.layout = {
+ title: 'Log in to your account',
+ description: 'Enter your email and password below to log in',
+};
diff --git a/resources/js/pages/auth/register.tsx b/resources/js/pages/auth/register.tsx
new file mode 100644
index 0000000..ce766ef
--- /dev/null
+++ b/resources/js/pages/auth/register.tsx
@@ -0,0 +1,120 @@
+import { Form, Head } from '@inertiajs/react';
+import InputError from '@/components/input-error';
+import PasswordInput from '@/components/password-input';
+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 { Spinner } from '@/components/ui/spinner';
+import { login } from '@/routes';
+import { store } from '@/routes/register';
+
+type Props = {
+ passwordRules: string;
+};
+
+export default function Register({ passwordRules }: Props) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+Register.layout = {
+ title: 'Create an account',
+ description: 'Enter your details below to create your account',
+};
diff --git a/resources/js/pages/auth/reset-password.tsx b/resources/js/pages/auth/reset-password.tsx
new file mode 100644
index 0000000..8b762c3
--- /dev/null
+++ b/resources/js/pages/auth/reset-password.tsx
@@ -0,0 +1,96 @@
+import { Form, Head } from '@inertiajs/react';
+import InputError from '@/components/input-error';
+import PasswordInput from '@/components/password-input';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Spinner } from '@/components/ui/spinner';
+import { update } from '@/routes/password';
+
+type Props = {
+ token: string;
+ email: string;
+ passwordRules: string;
+};
+
+export default function ResetPassword({ token, email, passwordRules }: Props) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+ResetPassword.layout = {
+ title: 'Reset password',
+ description: 'Please enter your new password below',
+};
diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx
new file mode 100644
index 0000000..3cddec3
--- /dev/null
+++ b/resources/js/pages/auth/two-factor-challenge.tsx
@@ -0,0 +1,133 @@
+import { Form, Head, setLayoutProps } from '@inertiajs/react';
+import { REGEXP_ONLY_DIGITS } from 'input-otp';
+import { useMemo, useState } from 'react';
+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 { store } from '@/routes/two-factor/login';
+
+export default function TwoFactorChallenge() {
+ const [showRecoveryInput, setShowRecoveryInput] = useState(false);
+ const [code, setCode] = useState('');
+
+ 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]);
+
+ setLayoutProps({
+ title: authConfigContent.title,
+ description: authConfigContent.description,
+ });
+
+ const toggleRecoveryMode = (clearErrors: () => void): void => {
+ setShowRecoveryInput(!showRecoveryInput);
+ clearErrors();
+ setCode('');
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/resources/js/pages/auth/verify-email.tsx b/resources/js/pages/auth/verify-email.tsx
new file mode 100644
index 0000000..a6fee14
--- /dev/null
+++ b/resources/js/pages/auth/verify-email.tsx
@@ -0,0 +1,46 @@
+// Components
+import { Form, Head } from '@inertiajs/react';
+import TextLink from '@/components/text-link';
+import { Button } from '@/components/ui/button';
+import { Spinner } from '@/components/ui/spinner';
+import { logout } from '@/routes';
+import { send } from '@/routes/verification';
+
+export default function VerifyEmail({ status }: { status?: string }) {
+ return (
+ <>
+
+
+ {status === 'verification-link-sent' && (
+
+ A new verification link has been sent to the email address
+ you provided during registration.
+
+ )}
+
+
+ >
+ );
+}
+
+VerifyEmail.layout = {
+ title: 'Email verification',
+ description:
+ 'Please verify your email address by clicking on the link we just emailed to you.',
+};
diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx
new file mode 100644
index 0000000..3fe0cc7
--- /dev/null
+++ b/resources/js/pages/dashboard.tsx
@@ -0,0 +1,36 @@
+import { Head } from '@inertiajs/react';
+import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
+import { dashboard } from '@/routes';
+
+export default function Dashboard() {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+Dashboard.layout = {
+ breadcrumbs: [
+ {
+ title: 'Dashboard',
+ href: dashboard(),
+ },
+ ],
+};
diff --git a/resources/js/pages/settings/appearance.tsx b/resources/js/pages/settings/appearance.tsx
new file mode 100644
index 0000000..ca18d76
--- /dev/null
+++ b/resources/js/pages/settings/appearance.tsx
@@ -0,0 +1,32 @@
+import { Head } from '@inertiajs/react';
+import AppearanceTabs from '@/components/appearance-tabs';
+import Heading from '@/components/heading';
+import { edit as editAppearance } from '@/routes/appearance';
+
+export default function Appearance() {
+ return (
+ <>
+
+
+ Appearance settings
+
+
+ >
+ );
+}
+
+Appearance.layout = {
+ breadcrumbs: [
+ {
+ title: 'Appearance settings',
+ href: editAppearance(),
+ },
+ ],
+};
diff --git a/resources/js/pages/settings/profile.tsx b/resources/js/pages/settings/profile.tsx
new file mode 100644
index 0000000..0a242cd
--- /dev/null
+++ b/resources/js/pages/settings/profile.tsx
@@ -0,0 +1,138 @@
+import { Form, Head, usePage } from '@inertiajs/react';
+import { Link } from '@inertiajs/react';
+import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
+import DeleteUser from '@/components/delete-user';
+import Heading from '@/components/heading';
+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 { edit } from '@/routes/profile';
+import { send } from '@/routes/verification';
+import type { Auth } from '@/types';
+
+type PageProps = {
+ auth: Auth;
+};
+
+export default function Profile({
+ mustVerifyEmail,
+ status,
+}: {
+ mustVerifyEmail: boolean;
+ status?: string;
+}) {
+ const { auth } = usePage().props;
+
+ return (
+ <>
+
+
+ Profile settings
+
+
+
+
+ >
+ );
+}
+
+Profile.layout = {
+ breadcrumbs: [
+ {
+ title: 'Profile settings',
+ href: edit(),
+ },
+ ],
+};
diff --git a/resources/js/pages/settings/security.tsx b/resources/js/pages/settings/security.tsx
new file mode 100644
index 0000000..1b8340e
--- /dev/null
+++ b/resources/js/pages/settings/security.tsx
@@ -0,0 +1,147 @@
+import { Form, Head } from '@inertiajs/react';
+import { useRef } from 'react';
+import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
+import Heading from '@/components/heading';
+import InputError from '@/components/input-error';
+import type { Props as ManagePasskeysProps } from '@/components/manage-passkeys';
+import ManagePasskeys from '@/components/manage-passkeys';
+import type { Props as ManageTwoFactorProps } from '@/components/manage-two-factor';
+import ManageTwoFactor from '@/components/manage-two-factor';
+import PasswordInput from '@/components/password-input';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { edit } from '@/routes/security';
+
+type Props = {
+ passwordRules: string;
+} & ManagePasskeysProps &
+ ManageTwoFactorProps;
+
+export default function Security(props: Props) {
+ const passwordInput = useRef(null);
+ const currentPasswordInput = useRef(null);
+
+ return (
+ <>
+
+
+ Security settings
+
+
+
+
+
+
+ >
+ );
+}
+
+Security.layout = {
+ breadcrumbs: [
+ {
+ title: 'Security settings',
+ href: edit(),
+ },
+ ],
+};
diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx
new file mode 100644
index 0000000..fd91ee3
--- /dev/null
+++ b/resources/js/pages/welcome.tsx
@@ -0,0 +1,389 @@
+import { Head, Link, usePage } from '@inertiajs/react';
+import { dashboard, login } from '@/routes';
+import { register } from '@/routes';
+
+export default function Welcome() {
+ const { auth } = usePage().props;
+
+ return (
+ <>
+
+
+
+
+ {auth.user ? (
+
+ Dashboard
+
+ ) : (
+ <>
+
+ Log in
+
+
+ Register
+
+ >
+ )}
+
+
+
+
+
+
+ Let's get started
+
+
+ Laravel has an incredibly rich ecosystem.
+
+ We suggest starting with the following.
+
+
+
+
+
+ {/* Laravel Logo */}
+
+
+
+
+
+
+
+
+
+
+ {/* 13 */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts
new file mode 100644
index 0000000..0a17aec
--- /dev/null
+++ b/resources/js/types/auth.ts
@@ -0,0 +1,32 @@
+export type 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;
+};
+
+export type Auth = {
+ user: User;
+};
+
+export type Passkey = {
+ id: number;
+ name: string;
+ authenticator: string | null;
+ created_at_diff: string;
+ last_used_at_diff: string | null;
+};
+
+export type TwoFactorSetupData = {
+ svg: string;
+ url: string;
+};
+
+export type TwoFactorSecretKey = {
+ secretKey: string;
+};
diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts
new file mode 100644
index 0000000..f3f0d6b
--- /dev/null
+++ b/resources/js/types/global.d.ts
@@ -0,0 +1,19 @@
+import type { Auth } from '@/types/auth';
+
+declare module 'react' {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ interface InputHTMLAttributes {
+ passwordrules?: string;
+ }
+}
+
+declare module '@inertiajs/core' {
+ export interface InertiaConfig {
+ sharedPageProps: {
+ name: string;
+ auth: Auth;
+ sidebarOpen: boolean;
+ [key: string]: unknown;
+ };
+ }
+}
diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts
new file mode 100644
index 0000000..b1f0ee6
--- /dev/null
+++ b/resources/js/types/index.ts
@@ -0,0 +1,3 @@
+export type * from './auth';
+export type * from './navigation';
+export type * from './ui';
diff --git a/resources/js/types/navigation.ts b/resources/js/types/navigation.ts
new file mode 100644
index 0000000..dbf6f83
--- /dev/null
+++ b/resources/js/types/navigation.ts
@@ -0,0 +1,14 @@
+import type { InertiaLinkProps } from '@inertiajs/react';
+import type { LucideIcon } from 'lucide-react';
+
+export type BreadcrumbItem = {
+ title: string;
+ href: NonNullable;
+};
+
+export type NavItem = {
+ title: string;
+ href: NonNullable;
+ icon?: LucideIcon | null;
+ isActive?: boolean;
+};
diff --git a/resources/js/types/ui.ts b/resources/js/types/ui.ts
new file mode 100644
index 0000000..0920e71
--- /dev/null
+++ b/resources/js/types/ui.ts
@@ -0,0 +1,21 @@
+import type { ReactNode } from 'react';
+import type { BreadcrumbItem } from '@/types/navigation';
+
+export type AppLayoutProps = {
+ children: ReactNode;
+ breadcrumbs?: BreadcrumbItem[];
+};
+
+export type AppVariant = 'header' | 'sidebar';
+
+export type FlashToast = {
+ type: 'success' | 'info' | 'warning' | 'error';
+ message: string;
+};
+
+export type AuthLayoutProps = {
+ children?: ReactNode;
+ name?: string;
+ title?: string;
+ description?: string;
+};
diff --git a/resources/js/types/vite-env.d.ts b/resources/js/types/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/resources/js/types/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php
new file mode 100644
index 0000000..20ce3be
--- /dev/null
+++ b/resources/views/app.blade.php
@@ -0,0 +1,48 @@
+
+ ($appearance ?? 'system') == 'dark'])>
+
+
+
+
+ {{-- Inline script to detect system dark mode preference and apply it immediately --}}
+
+
+ {{-- Inline style to set the HTML background color based on our theme in app.css --}}
+
+
+
+
+
+
+ @fonts
+
+ @viteReactRefresh
+ @vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
+
+ {{ config('app.name', 'Laravel') }}
+
+
+
+
+
+
diff --git a/routes/console.php b/routes/console.php
new file mode 100644
index 0000000..3c9adf1
--- /dev/null
+++ b/routes/console.php
@@ -0,0 +1,8 @@
+comment(Inspiring::quote());
+})->purpose('Display an inspiring quote');
diff --git a/routes/settings.php b/routes/settings.php
new file mode 100644
index 0000000..d74f967
--- /dev/null
+++ b/routes/settings.php
@@ -0,0 +1,34 @@
+group(function () {
+ Route::redirect('settings', '/settings/profile');
+
+ Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
+ Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
+});
+
+Route::middleware(['auth', 'verified'])->group(function () {
+ Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
+
+ Route::get('settings/security', [SecurityController::class, 'edit'])
+ ->middleware(RequirePassword::class)
+ ->name('security.edit');
+
+ Route::put('settings/password', [SecurityController::class, 'update'])
+ ->middleware('throttle:6,1')
+ ->name('user-password.update');
+
+ Route::inertia('settings/appearance', 'settings/appearance')->name('appearance.edit');
+});
+
+Route::get('.well-known/passkey-endpoints', function () {
+ return response()->json([
+ 'enroll' => route('security.edit'),
+ 'manage' => route('security.edit'),
+ ]);
+})->name('well-known.passkeys');
diff --git a/routes/web.php b/routes/web.php
new file mode 100644
index 0000000..2085b21
--- /dev/null
+++ b/routes/web.php
@@ -0,0 +1,11 @@
+name('home');
+
+Route::middleware(['auth', 'verified'])->group(function () {
+ Route::inertia('dashboard', 'dashboard')->name('dashboard');
+});
+
+require __DIR__.'/settings.php';
diff --git a/storage/app/.gitignore b/storage/app/.gitignore
new file mode 100644
index 0000000..fedb287
--- /dev/null
+++ b/storage/app/.gitignore
@@ -0,0 +1,4 @@
+*
+!private/
+!public/
+!.gitignore
diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/storage/app/private/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/storage/app/public/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore
new file mode 100644
index 0000000..05c4471
--- /dev/null
+++ b/storage/framework/.gitignore
@@ -0,0 +1,9 @@
+compiled.php
+config.php
+down
+events.scanned.php
+maintenance.php
+routes.php
+routes.scanned.php
+schedule-*
+services.json
diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore
new file mode 100644
index 0000000..01e4a6c
--- /dev/null
+++ b/storage/framework/cache/.gitignore
@@ -0,0 +1,3 @@
+*
+!data/
+!.gitignore
diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/storage/framework/cache/data/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/storage/framework/sessions/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/storage/framework/testing/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/storage/framework/views/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/storage/logs/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php
new file mode 100644
index 0000000..d1b9ed2
--- /dev/null
+++ b/tests/Feature/Auth/AuthenticationTest.php
@@ -0,0 +1,77 @@
+get(route('login'));
+
+ $response->assertOk();
+});
+
+test('users can authenticate using the login screen', function () {
+ $user = User::factory()->create();
+
+ $response = $this->post(route('login.store'), [
+ 'email' => $user->email,
+ 'password' => 'password',
+ ]);
+
+ $this->assertAuthenticated();
+ $response->assertRedirect(route('dashboard', absolute: false));
+});
+
+test('users with two factor enabled are redirected to two factor challenge', function () {
+ $this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
+
+ Features::twoFactorAuthentication([
+ 'confirm' => true,
+ 'confirmPassword' => true,
+ ]);
+
+ $user = User::factory()->withTwoFactor()->create();
+
+ $response = $this->post(route('login'), [
+ 'email' => $user->email,
+ 'password' => 'password',
+ ]);
+
+ $response->assertRedirect(route('two-factor.login'));
+ $response->assertSessionHas('login.id', $user->id);
+ $this->assertGuest();
+});
+
+test('users can not authenticate with invalid password', function () {
+ $user = User::factory()->create();
+
+ $this->post(route('login.store'), [
+ 'email' => $user->email,
+ 'password' => 'wrong-password',
+ ]);
+
+ $this->assertGuest();
+});
+
+test('users can logout', function () {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('logout'));
+
+ $response->assertRedirect(route('home'));
+
+ $this->assertGuest();
+});
+
+test('users are rate limited', function () {
+ $user = User::factory()->create();
+
+ RateLimiter::increment(md5('login'.implode('|', [$user->email, '127.0.0.1'])), amount: 5);
+
+ $response = $this->post(route('login.store'), [
+ 'email' => $user->email,
+ 'password' => 'wrong-password',
+ ]);
+
+ $response->assertTooManyRequests();
+});
diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php
new file mode 100644
index 0000000..81eacd8
--- /dev/null
+++ b/tests/Feature/Auth/EmailVerificationTest.php
@@ -0,0 +1,101 @@
+skipUnlessFortifyHas(Features::emailVerification());
+});
+
+test('email verification screen can be rendered', function () {
+ $user = User::factory()->unverified()->create();
+
+ $response = $this->actingAs($user)->get(route('verification.notice'));
+
+ $response->assertOk();
+});
+
+test('email can be verified', function () {
+ $user = User::factory()->unverified()->create();
+
+ Event::fake();
+
+ $verificationUrl = URL::temporarySignedRoute(
+ 'verification.verify',
+ now()->addMinutes(60),
+ ['id' => $user->id, 'hash' => sha1($user->email)],
+ );
+
+ $response = $this->actingAs($user)->get($verificationUrl);
+
+ Event::assertDispatched(Verified::class);
+
+ expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
+ $response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
+});
+
+test('email is not verified with invalid hash', function () {
+ $user = User::factory()->unverified()->create();
+
+ Event::fake();
+
+ $verificationUrl = URL::temporarySignedRoute(
+ 'verification.verify',
+ now()->addMinutes(60),
+ ['id' => $user->id, 'hash' => sha1('wrong-email')],
+ );
+
+ $this->actingAs($user)->get($verificationUrl);
+
+ Event::assertNotDispatched(Verified::class);
+ expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
+});
+
+test('email is not verified with invalid user id', function () {
+ $user = User::factory()->unverified()->create();
+
+ Event::fake();
+
+ $verificationUrl = URL::temporarySignedRoute(
+ 'verification.verify',
+ now()->addMinutes(60),
+ ['id' => 123, 'hash' => sha1($user->email)],
+ );
+
+ $this->actingAs($user)->get($verificationUrl);
+
+ Event::assertNotDispatched(Verified::class);
+ expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
+});
+
+test('verified user is redirected to dashboard from verification prompt', function () {
+ $user = User::factory()->create();
+
+ Event::fake();
+
+ $response = $this->actingAs($user)->get(route('verification.notice'));
+
+ Event::assertNotDispatched(Verified::class);
+ $response->assertRedirect(route('dashboard', absolute: false));
+});
+
+test('already verified user visiting verification link is redirected without firing event again', function () {
+ $user = User::factory()->create();
+
+ Event::fake();
+
+ $verificationUrl = URL::temporarySignedRoute(
+ 'verification.verify',
+ now()->addMinutes(60),
+ ['id' => $user->id, 'hash' => sha1($user->email)],
+ );
+
+ $this->actingAs($user)->get($verificationUrl)
+ ->assertRedirect(route('dashboard', absolute: false).'?verified=1');
+
+ Event::assertNotDispatched(Verified::class);
+ expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
+});
diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php
new file mode 100644
index 0000000..5d58992
--- /dev/null
+++ b/tests/Feature/Auth/PasswordConfirmationTest.php
@@ -0,0 +1,22 @@
+create();
+
+ $response = $this->actingAs($user)->get(route('password.confirm'));
+
+ $response->assertOk();
+
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('auth/confirm-password'),
+ );
+});
+
+test('password confirmation requires authentication', function () {
+ $response = $this->get(route('password.confirm'));
+
+ $response->assertRedirect(route('login'));
+});
diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php
new file mode 100644
index 0000000..a2b8010
--- /dev/null
+++ b/tests/Feature/Auth/PasswordResetTest.php
@@ -0,0 +1,78 @@
+skipUnlessFortifyHas(Features::resetPasswords());
+});
+
+test('reset password link screen can be rendered', function () {
+ $response = $this->get(route('password.request'));
+
+ $response->assertOk();
+});
+
+test('reset password link can be requested', function () {
+ Notification::fake();
+
+ $user = User::factory()->create();
+
+ $this->post(route('password.email'), ['email' => $user->email]);
+
+ Notification::assertSentTo($user, ResetPassword::class);
+});
+
+test('reset password screen can be rendered', function () {
+ Notification::fake();
+
+ $user = User::factory()->create();
+
+ $this->post(route('password.email'), ['email' => $user->email]);
+
+ Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
+ $response = $this->get(route('password.reset', $notification->token));
+
+ $response->assertOk();
+
+ return true;
+ });
+});
+
+test('password can be reset with valid token', function () {
+ Notification::fake();
+
+ $user = User::factory()->create();
+
+ $this->post(route('password.email'), ['email' => $user->email]);
+
+ Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
+ $response = $this->post(route('password.update'), [
+ 'token' => $notification->token,
+ 'email' => $user->email,
+ 'password' => 'password',
+ 'password_confirmation' => 'password',
+ ]);
+
+ $response
+ ->assertSessionHasNoErrors()
+ ->assertRedirect(route('login'));
+
+ return true;
+ });
+});
+
+test('password cannot be reset with invalid token', function () {
+ $user = User::factory()->create();
+
+ $response = $this->post(route('password.update'), [
+ 'token' => 'invalid-token',
+ 'email' => $user->email,
+ 'password' => 'newpassword123',
+ 'password_confirmation' => 'newpassword123',
+ ]);
+
+ $response->assertSessionHasErrors('email');
+});
diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php
new file mode 100644
index 0000000..35ec7ca
--- /dev/null
+++ b/tests/Feature/Auth/RegistrationTest.php
@@ -0,0 +1,25 @@
+skipUnlessFortifyHas(Features::registration());
+});
+
+test('registration screen can be rendered', function () {
+ $response = $this->get(route('register'));
+
+ $response->assertOk();
+});
+
+test('new users can register', function () {
+ $response = $this->post(route('register.store'), [
+ 'name' => 'Test User',
+ 'email' => 'test@example.com',
+ 'password' => 'password',
+ 'password_confirmation' => 'password',
+ ]);
+
+ $this->assertAuthenticated();
+ $response->assertRedirect(route('dashboard', absolute: false));
+});
diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php
new file mode 100644
index 0000000..62e33d7
--- /dev/null
+++ b/tests/Feature/Auth/TwoFactorChallengeTest.php
@@ -0,0 +1,35 @@
+skipUnlessFortifyHas(Features::twoFactorAuthentication());
+});
+
+test('two factor challenge redirects to login when not authenticated', function () {
+ $response = $this->get(route('two-factor.login'));
+
+ $response->assertRedirect(route('login'));
+});
+
+test('two factor challenge can be rendered', function () {
+ Features::twoFactorAuthentication([
+ 'confirm' => true,
+ 'confirmPassword' => true,
+ ]);
+
+ $user = User::factory()->withTwoFactor()->create();
+
+ $this->post(route('login'), [
+ 'email' => $user->email,
+ 'password' => 'password',
+ ]);
+
+ $this->get(route('two-factor.login'))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('auth/two-factor-challenge'),
+ );
+});
diff --git a/tests/Feature/Auth/VerificationNotificationTest.php b/tests/Feature/Auth/VerificationNotificationTest.php
new file mode 100644
index 0000000..2e7c33f
--- /dev/null
+++ b/tests/Feature/Auth/VerificationNotificationTest.php
@@ -0,0 +1,34 @@
+skipUnlessFortifyHas(Features::emailVerification());
+});
+
+test('sends verification notification', function () {
+ Notification::fake();
+
+ $user = User::factory()->unverified()->create();
+
+ $this->actingAs($user)
+ ->post(route('verification.send'))
+ ->assertRedirect(route('home'));
+
+ Notification::assertSentTo($user, VerifyEmail::class);
+});
+
+test('does not send verification notification if email is verified', function () {
+ Notification::fake();
+
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->post(route('verification.send'))
+ ->assertRedirect(route('dashboard', absolute: false));
+
+ Notification::assertNothingSent();
+});
diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php
new file mode 100644
index 0000000..95c6a8b
--- /dev/null
+++ b/tests/Feature/DashboardTest.php
@@ -0,0 +1,16 @@
+get(route('dashboard'));
+ $response->assertRedirect(route('login'));
+});
+
+test('authenticated users can visit the dashboard', function () {
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $response = $this->get(route('dashboard'));
+ $response->assertOk();
+});
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
new file mode 100644
index 0000000..ef7b533
--- /dev/null
+++ b/tests/Feature/ExampleTest.php
@@ -0,0 +1,7 @@
+get(route('home'));
+
+ $response->assertOk();
+});
diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php
new file mode 100644
index 0000000..9f49e25
--- /dev/null
+++ b/tests/Feature/Settings/ProfileUpdateTest.php
@@ -0,0 +1,85 @@
+create();
+
+ $response = $this
+ ->actingAs($user)
+ ->get(route('profile.edit'));
+
+ $response->assertOk();
+});
+
+test('profile information can be updated', function () {
+ $user = User::factory()->create();
+
+ $response = $this
+ ->actingAs($user)
+ ->patch(route('profile.update'), [
+ 'name' => 'Test User',
+ 'email' => 'test@example.com',
+ ]);
+
+ $response
+ ->assertSessionHasNoErrors()
+ ->assertRedirect(route('profile.edit'));
+
+ $user->refresh();
+
+ expect($user->name)->toBe('Test User');
+ expect($user->email)->toBe('test@example.com');
+ expect($user->email_verified_at)->toBeNull();
+});
+
+test('email verification status is unchanged when the email address is unchanged', function () {
+ $user = User::factory()->create();
+
+ $response = $this
+ ->actingAs($user)
+ ->patch(route('profile.update'), [
+ 'name' => 'Test User',
+ 'email' => $user->email,
+ ]);
+
+ $response
+ ->assertSessionHasNoErrors()
+ ->assertRedirect(route('profile.edit'));
+
+ expect($user->refresh()->email_verified_at)->not->toBeNull();
+});
+
+test('user can delete their account', function () {
+ $user = User::factory()->create();
+
+ $response = $this
+ ->actingAs($user)
+ ->delete(route('profile.destroy'), [
+ 'password' => 'password',
+ ]);
+
+ $response
+ ->assertSessionHasNoErrors()
+ ->assertRedirect(route('home'));
+
+ $this->assertGuest();
+ expect($user->fresh())->toBeNull();
+});
+
+test('correct password must be provided to delete account', function () {
+ $user = User::factory()->create();
+
+ $response = $this
+ ->actingAs($user)
+ ->from(route('profile.edit'))
+ ->delete(route('profile.destroy'), [
+ 'password' => 'wrong-password',
+ ]);
+
+ $response
+ ->assertSessionHasErrors('password')
+ ->assertRedirect(route('profile.edit'));
+
+ expect($user->fresh())->not->toBeNull();
+});
diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php
new file mode 100644
index 0000000..c190619
--- /dev/null
+++ b/tests/Feature/Settings/SecurityTest.php
@@ -0,0 +1,104 @@
+skipUnlessFortifyHas(Features::twoFactorAuthentication());
+
+ Features::twoFactorAuthentication([
+ 'confirm' => true,
+ 'confirmPassword' => true,
+ ]);
+ Features::passkeys([
+ 'confirmPassword' => true,
+ ]);
+
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->withSession(['auth.password_confirmed_at' => time()])
+ ->get(route('security.edit'))
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('settings/security')
+ ->where('canManagePasskeys', true)
+ ->where('passkeys', [])
+ ->where('canManageTwoFactor', true)
+ ->where('twoFactorEnabled', false),
+ );
+});
+
+test('security page requires password confirmation when enabled', function () {
+ $this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
+
+ $user = User::factory()->create();
+
+ Features::twoFactorAuthentication([
+ 'confirm' => true,
+ 'confirmPassword' => true,
+ ]);
+
+ $response = $this->actingAs($user)
+ ->get(route('security.edit'));
+
+ $response->assertRedirect(route('password.confirm'));
+});
+
+test('security page renders without two factor when feature is disabled', function () {
+ $this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
+
+ config(['fortify.features' => []]);
+
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->withSession(['auth.password_confirmed_at' => time()])
+ ->get(route('security.edit'))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('settings/security')
+ ->where('canManagePasskeys', false)
+ ->where('passkeys', [])
+ ->where('canManageTwoFactor', false)
+ ->missing('twoFactorEnabled')
+ ->missing('requiresConfirmation'),
+ );
+});
+
+test('password can be updated', function () {
+ $user = User::factory()->create();
+
+ $response = $this
+ ->actingAs($user)
+ ->from(route('security.edit'))
+ ->put(route('user-password.update'), [
+ 'current_password' => 'password',
+ 'password' => 'new-password',
+ 'password_confirmation' => 'new-password',
+ ]);
+
+ $response
+ ->assertSessionHasNoErrors()
+ ->assertRedirect(route('security.edit'));
+
+ expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
+});
+
+test('correct password must be provided to update password', function () {
+ $user = User::factory()->create();
+
+ $response = $this
+ ->actingAs($user)
+ ->from(route('security.edit'))
+ ->put(route('user-password.update'), [
+ 'current_password' => 'wrong-password',
+ 'password' => 'new-password',
+ 'password_confirmation' => 'new-password',
+ ]);
+
+ $response
+ ->assertSessionHasErrors('current_password')
+ ->assertRedirect(route('security.edit'));
+});
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..941e024
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,50 @@
+extend(TestCase::class)
+ ->use(RefreshDatabase::class)
+ ->in('Feature');
+
+/*
+|--------------------------------------------------------------------------
+| Expectations
+|--------------------------------------------------------------------------
+|
+| When you're writing tests, you often need to check that values meet certain conditions. The
+| "expect()" function gives you access to a set of "expectations" methods that you can use
+| to assert different things. Of course, you may extend the Expectation API at any time.
+|
+*/
+
+expect()->extend('toBeOne', function () {
+ return $this->toBe(1);
+});
+
+/*
+|--------------------------------------------------------------------------
+| Functions
+|--------------------------------------------------------------------------
+|
+| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
+| project that you don't want to repeat in every file. Here you can also expose helpers as
+| global functions to help you to reduce the number of lines of code in your test files.
+|
+*/
+
+function something()
+{
+ // ..
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..a574f37
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,16 @@
+markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled.");
+ }
+ }
+}
diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php
new file mode 100644
index 0000000..44a4f33
--- /dev/null
+++ b/tests/Unit/ExampleTest.php
@@ -0,0 +1,5 @@
+toBeTrue();
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..ad14526
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,121 @@
+{
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig to read more about this file */
+
+ /* Projects */
+ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
+ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
+ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
+ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
+ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
+
+ /* Language and Environment */
+ "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
+ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+ // "jsx": "preserve", /* Specify what JSX code is generated. */
+ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
+ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
+ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
+ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
+ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
+
+ /* Modules */
+ "module": "ESNext" /* Specify what module code is generated. */,
+ // "rootDir": "./", /* Specify the root folder within your source files. */
+ "moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
+ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
+ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
+ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
+ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
+ // "types": [], /* Specify type package names to be included without being referenced in a source file. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
+ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
+ // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
+ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
+ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
+ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
+ // "noUncheckedSideEffectImports": true, /* Check side effect imports. */
+ // "resolveJsonModule": true, /* Enable importing .json files. */
+ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
+ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
+
+ /* JavaScript Support */
+ "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */,
+ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
+ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+
+ /* Emit */
+ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+ // "declarationMap": true, /* Create sourcemaps for d.ts files. */
+ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
+ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
+ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
+ "noEmit": true /* Disable emitting files from a compilation. */,
+ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+ // "outDir": "./", /* Specify an output folder for all emitted files. */
+ // "removeComments": true, /* Disable emitting comments. */
+ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
+ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+ // "newLine": "crlf", /* Set the newline character for emitting files. */
+ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
+ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
+ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
+ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
+
+ /* Interop Constraints */
+ "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
+ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
+ // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
+ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
+ "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
+ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+ "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
+
+ /* Type Checking */
+ "strict": true /* Enable all strict type-checking options. */,
+ "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */,
+ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
+ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
+ // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
+ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
+ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
+ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
+ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
+ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
+ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
+ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
+ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
+ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
+ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
+ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
+ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
+ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
+
+ /* Completeness */
+ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
+ "skipLibCheck": true /* Skip type checking all .d.ts files. */,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./resources/js/*"]
+ },
+ "jsx": "react-jsx"
+ },
+ "include": [
+ "resources/js/**/*.ts",
+ "resources/js/**/*.d.ts",
+ "resources/js/**/*.tsx"
+ ]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..b855732
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,31 @@
+import inertia from '@inertiajs/vite';
+import { wayfinder } from '@laravel/vite-plugin-wayfinder';
+import tailwindcss from '@tailwindcss/vite';
+import react from '@vitejs/plugin-react';
+import laravel from 'laravel-vite-plugin';
+import { bunny } from 'laravel-vite-plugin/fonts';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [
+ laravel({
+ input: ['resources/css/app.css', 'resources/js/app.tsx'],
+ refresh: true,
+ fonts: [
+ bunny('Instrument Sans', {
+ weights: [400, 500, 600],
+ }),
+ ],
+ }),
+ inertia(),
+ react({
+ babel: {
+ plugins: ['babel-plugin-react-compiler'],
+ },
+ }),
+ tailwindcss(),
+ wayfinder({
+ formVariants: true,
+ }),
+ ],
+});