feat(ui): add comprehensive quality gates and fix linting issues

- Added ESLint, Prettier, TypeScript strict mode to ui/ directory
- Fixed 40+ lint/format issues across ui codebase
- Created extraction files for react-refresh compliance:
  - button-variants.ts for Button component styles
  - use-sidebar.ts, use-websocket-context.ts custom hooks
  - ws-context.ts WebSocket context provider
- Fixed React hooks issues (setState ordering, dependency arrays)
- Added ui:validate script to enforce quality checks
- Updated husky pre-commit to validate UI files on change
This commit is contained in:
kaitranntt
2025-12-07 14:51:29 -05:00
parent 59758024c9
commit 707af2f01a
59 changed files with 929 additions and 2835 deletions
+6
View File
@@ -1,2 +1,8 @@
# Run validation before commit (typecheck + lint + format + tests) # Run validation before commit (typecheck + lint + format + tests)
bun run validate bun run validate
# Validate UI if changes detected
if git diff --cached --name-only | grep -q "^ui/"; then
echo "[i] UI changes detected, running ui:validate..."
bun run ui:validate
fi
+43
View File
@@ -38,6 +38,49 @@ bun run format # Auto-fix formatting
- `src/` - TypeScript source (55 modules) - `src/` - TypeScript source (55 modules)
- `dist/` - Compiled JavaScript (npm package) - `dist/` - Compiled JavaScript (npm package)
- `lib/` - Native shell scripts (bash, PowerShell) - `lib/` - Native shell scripts (bash, PowerShell)
- `ui/` - React dashboard (Vite + React 19 + shadcn/ui)
## UI Quality Gates (React Dashboard)
**The ui/ directory has IDENTICAL quality gates to the main project.**
**Package Manager: bun** (same as root)
```bash
cd ui
bun install # Install dependencies
bun run build # TypeScript + Vite build
bun run validate # Full validation: typecheck + lint:fix + format:check
```
**Fix issues before committing:**
```bash
cd ui
bun run typecheck # Type check only
bun run lint:fix # Auto-fix lint issues
bun run format # Auto-fix formatting
bun run format:check # Verify formatting (no changes)
```
**Linting rules (ui/eslint.config.js) - ALL errors:**
- `@typescript-eslint/no-unused-vars` - error (ignore `_` prefix)
- `@typescript-eslint/no-explicit-any` - error
- `@typescript-eslint/no-non-null-assertion` - error
- `prefer-const`, `no-var`, `eqeqeq` - error
- `react-hooks/exhaustive-deps` - warning
- `react-refresh/only-export-components` - error
**Type safety (ui/tsconfig.app.json):**
- `strict: true` with `verbatimModuleSyntax` enabled
- `noUnusedLocals`, `noUnusedParameters` - enabled
- Type-only imports required: `import type { X }` for types
**UI file structure:**
- `ui/src/` - React components, hooks, pages
- `ui/src/components/ui/` - shadcn/ui components
- `ui/src/hooks/` - Custom React hooks
- `ui/src/pages/` - Route pages
- `ui/src/providers/` - Context providers
- `dist/ui/` - Built bundle (served by Express)
**Linting rules (eslint.config.mjs) - ALL errors:** **Linting rules (eslint.config.mjs) - ALL errors:**
- `@typescript-eslint/no-unused-vars` - error (ignore `_` prefix) - `@typescript-eslint/no-unused-vars` - error (ignore `_` prefix)
+1
View File
@@ -72,6 +72,7 @@
"ui:dev": "cd ui && bun run dev", "ui:dev": "cd ui && bun run dev",
"ui:build": "cd ui && bun run build", "ui:build": "cd ui && bun run build",
"ui:preview": "cd ui && bun run preview", "ui:preview": "cd ui && bun run preview",
"ui:validate": "cd ui && bun run validate",
"prepublishOnly": "bun run validate && node scripts/sync-version.js", "prepublishOnly": "bun run validate && node scripts/sync-version.js",
"prepack": "bun run validate && node scripts/sync-version.js", "prepack": "bun run validate && node scripts/sync-version.js",
"prepare": "husky", "prepare": "husky",
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always"
}
-155
View File
@@ -1,155 +0,0 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
-60
View File
@@ -1,60 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
-92
View File
@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
-141
View File
@@ -1,141 +0,0 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
-255
View File
@@ -1,255 +0,0 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
-21
View File
@@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
-24
View File
@@ -1,24 +0,0 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
-28
View File
@@ -1,28 +0,0 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
-137
View File
@@ -1,137 +0,0 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
-724
View File
@@ -1,724 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
-13
View File
@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
-114
View File
@@ -1,114 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
-61
View File
@@ -1,61 +0,0 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
-19
View File
@@ -1,19 +0,0 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(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
}
+6
View File
@@ -34,9 +34,11 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
@@ -437,6 +439,8 @@
"eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="], "eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="],
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.24", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w=="], "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.24", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w=="],
@@ -587,6 +591,8 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], "react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="],
+23 -7
View File
@@ -1,9 +1,10 @@
import js from '@eslint/js' import js from '@eslint/js';
import globals from 'globals' import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config';
import prettier from 'eslint-config-prettier';
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist']),
@@ -19,5 +20,20 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: {
// TypeScript rules - strict mode matching main project
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
// General code quality
'prefer-const': 'error',
'no-var': 'error',
eqeqeq: ['error', 'always'],
},
}, },
]) prettier,
]);
+7
View File
@@ -6,7 +6,12 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"typecheck": "tsc -b --noEmit",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"validate": "bun run typecheck && bun run lint:fix && bun run format:check",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -40,9 +45,11 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
+9 -9
View File
@@ -7,16 +7,16 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog';
interface ConfirmDialogProps { interface ConfirmDialogProps {
open: boolean open: boolean;
onConfirm: () => void onConfirm: () => void;
onCancel: () => void onCancel: () => void;
title: string title: string;
description: string description: string;
confirmText?: string confirmText?: string;
variant?: 'default' | 'destructive' variant?: 'default' | 'destructive';
} }
export function ConfirmDialog({ export function ConfirmDialog({
@@ -46,5 +46,5 @@ export function ConfirmDialog({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
) );
} }
+16 -18
View File
@@ -1,15 +1,15 @@
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button';
import { CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react' import { CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react';
import { useFixHealth } from '@/hooks/use-health' import { useFixHealth } from '@/hooks/use-health';
interface HealthCheck { interface HealthCheck {
id: string id: string;
name: string name: string;
status: 'ok' | 'warning' | 'error' status: 'ok' | 'warning' | 'error';
message: string message: string;
details?: string details?: string;
fixable?: boolean fixable?: boolean;
} }
const statusConfig = { const statusConfig = {
@@ -31,12 +31,12 @@ const statusConfig = {
bg: 'bg-red-50 dark:bg-red-900/20', bg: 'bg-red-50 dark:bg-red-900/20',
border: 'border-red-200 dark:border-red-800', border: 'border-red-200 dark:border-red-800',
}, },
} };
export function HealthCard({ check }: { check: HealthCheck }) { export function HealthCard({ check }: { check: HealthCheck }) {
const fixMutation = useFixHealth() const fixMutation = useFixHealth();
const config = statusConfig[check.status] const config = statusConfig[check.status];
const Icon = config.icon const Icon = config.icon;
return ( return (
<Card className={`${config.bg} ${config.border} border`}> <Card className={`${config.bg} ${config.border} border`}>
@@ -60,11 +60,9 @@ export function HealthCard({ check }: { check: HealthCheck }) {
</div> </div>
<p className="text-sm text-muted-foreground mt-2">{check.message}</p> <p className="text-sm text-muted-foreground mt-2">{check.message}</p>
{check.details && ( {check.details && (
<p className="text-xs text-muted-foreground mt-1 font-mono truncate"> <p className="text-xs text-muted-foreground mt-1 font-mono truncate">{check.details}</p>
{check.details}
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
) );
} }
+7 -7
View File
@@ -1,10 +1,10 @@
import { Shield, X } from 'lucide-react' import { Shield, X } from 'lucide-react';
import { useState } from 'react' import { useState } from 'react';
export function LocalhostDisclaimer() { export function LocalhostDisclaimer() {
const [dismissed, setDismissed] = useState(false) const [dismissed, setDismissed] = useState(false);
if (dismissed) return null if (dismissed) return null;
return ( return (
<div className="fixed bottom-0 left-0 right-0 bg-yellow-50 dark:bg-yellow-900/20 border-t border-yellow-200 dark:border-yellow-800 px-4 py-2"> <div className="fixed bottom-0 left-0 right-0 bg-yellow-50 dark:bg-yellow-900/20 border-t border-yellow-200 dark:border-yellow-800 px-4 py-2">
@@ -12,8 +12,8 @@ export function LocalhostDisclaimer() {
<div className="flex items-center gap-2 text-sm text-yellow-800 dark:text-yellow-200"> <div className="flex items-center gap-2 text-sm text-yellow-800 dark:text-yellow-200">
<Shield className="w-4 h-4" /> <Shield className="w-4 h-4" />
<span> <span>
This dashboard runs locally. All data stays on your machine. This dashboard runs locally. All data stays on your machine. Never expose this server to
Never expose this server to the internet. the internet.
</span> </span>
</div> </div>
<button <button
@@ -24,5 +24,5 @@ export function LocalhostDisclaimer() {
</button> </button>
</div> </div>
</div> </div>
) );
} }
+6 -29
View File
@@ -6,12 +6,7 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
import { import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -90,22 +85,13 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div> <div>
<Label htmlFor="name">Name</Label> <Label htmlFor="name">Name</Label>
<Input <Input id="name" {...register('name')} placeholder="my-api" disabled={!!profile} />
id="name"
{...register('name')}
placeholder="my-api"
disabled={!!profile}
/>
{errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>} {errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>}
</div> </div>
<div> <div>
<Label htmlFor="baseUrl">Base URL</Label> <Label htmlFor="baseUrl">Base URL</Label>
<Input <Input id="baseUrl" {...register('baseUrl')} placeholder="https://api.example.com" />
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com"
/>
{errors.baseUrl && ( {errors.baseUrl && (
<span className="text-xs text-red-500">{errors.baseUrl.message}</span> <span className="text-xs text-red-500">{errors.baseUrl.message}</span>
)} )}
@@ -114,28 +100,19 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<div> <div>
<Label htmlFor="apiKey">API Key</Label> <Label htmlFor="apiKey">API Key</Label>
<Input id="apiKey" type="password" {...register('apiKey')} /> <Input id="apiKey" type="password" {...register('apiKey')} />
{errors.apiKey && ( {errors.apiKey && <span className="text-xs text-red-500">{errors.apiKey.message}</span>}
<span className="text-xs text-red-500">{errors.apiKey.message}</span>
)}
</div> </div>
<div> <div>
<Label htmlFor="model">Model (optional)</Label> <Label htmlFor="model">Model (optional)</Label>
<Input <Input id="model" {...register('model')} placeholder="claude-sonnet-4-5-20250929" />
id="model"
{...register('model')}
placeholder="claude-sonnet-4-5-20250929"
/>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button <Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
>
{createMutation.isPending || updateMutation.isPending {createMutation.isPending || updateMutation.isPending
? 'Saving...' ? 'Saving...'
: profile : profile
+1 -6
View File
@@ -3,12 +3,7 @@
* Phase 03: REST API Routes & CRUD * Phase 03: REST API Routes & CRUD
*/ */
import { import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} from '@tanstack/react-table';
import { import {
Table, Table,
TableBody, TableBody,
+15 -9
View File
@@ -1,15 +1,21 @@
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react';
interface StatCardProps { interface StatCardProps {
title: string title: string;
value: number | string value: number | string;
icon: LucideIcon icon: LucideIcon;
color?: string color?: string;
onClick?: () => void onClick?: () => void;
} }
export function StatCard({ title, value, icon: Icon, color = 'text-primary', onClick }: StatCardProps) { export function StatCard({
title,
value,
icon: Icon,
color = 'text-primary',
onClick,
}: StatCardProps) {
return ( return (
<Card <Card
className={`cursor-pointer hover:shadow-md transition-shadow ${onClick ? 'hover:border-primary' : ''}`} className={`cursor-pointer hover:shadow-md transition-shadow ${onClick ? 'hover:border-primary' : ''}`}
@@ -25,5 +31,5 @@ export function StatCard({ title, value, icon: Icon, color = 'text-primary', onC
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
+27 -49
View File
@@ -1,29 +1,21 @@
import * as React from "react" import * as React from 'react';
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from '@/components/ui/button-variants';
function AlertDialog({ function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
...props return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
} }
function AlertDialogTrigger({ function AlertDialogTrigger({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return ( return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
} }
function AlertDialogPortal({ function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
...props return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
} }
function AlertDialogOverlay({ function AlertDialogOverlay({
@@ -34,12 +26,12 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogContent({ function AlertDialogContent({
@@ -52,42 +44,33 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className className
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
) );
} }
function AlertDialogHeader({ function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-dialog-header" data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogFooter({ function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
className={cn( className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props} {...props}
/> />
) );
} }
function AlertDialogTitle({ function AlertDialogTitle({
@@ -97,10 +80,10 @@ function AlertDialogTitle({
return ( return (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
data-slot="alert-dialog-title" data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)} className={cn('text-lg font-semibold', className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogDescription({ function AlertDialogDescription({
@@ -110,22 +93,17 @@ function AlertDialogDescription({
return ( return (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
data-slot="alert-dialog-description" data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogAction({ function AlertDialogAction({
className, className,
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return ( return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />;
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
} }
function AlertDialogCancel({ function AlertDialogCancel({
@@ -134,10 +112,10 @@ function AlertDialogCancel({
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return ( return (
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)} className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -152,4 +130,4 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };
+31
View File
@@ -0,0 +1,31 @@
import { cva } from 'class-variance-authority';
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
+10 -41
View File
@@ -1,40 +1,9 @@
import * as React from "react" import * as React from 'react';
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from "class-variance-authority" import { type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { buttonVariants } from './button-variants';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({ function Button({
className, className,
@@ -42,11 +11,11 @@ function Button({
size, size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
@@ -54,7 +23,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button };
+23 -40
View File
@@ -1,92 +1,75 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", 'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-title" data-slot="card-title"
className={cn("leading-none font-semibold", className)} className={cn('leading-none font-semibold', className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return ( return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props} {...props}
/> />
) );
} }
export { export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+29 -43
View File
@@ -1,31 +1,23 @@
import * as React from "react" import * as React from 'react';
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from "lucide-react" import { XIcon } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Dialog({ function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
...props return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
} }
function DialogTrigger({ function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
...props return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
} }
function DialogPortal({ function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
...props return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
} }
function DialogClose({ function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
...props return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
} }
function DialogOverlay({ function DialogOverlay({
@@ -36,12 +28,12 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -50,7 +42,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@@ -58,7 +50,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className className
)} )}
{...props} {...props}
@@ -75,43 +67,37 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="dialog-header" data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props} {...props}
/> />
) );
} }
function DialogTitle({ function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={cn('text-lg leading-none font-semibold', className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@@ -121,10 +107,10 @@ function DialogDescription({
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -138,4 +124,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
+34 -63
View File
@@ -1,32 +1,23 @@
import * as React from "react" import * as React from 'react';
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function DropdownMenu({ function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
...props return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return ( return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
} }
function DropdownMenuContent({ function DropdownMenuContent({
@@ -40,31 +31,27 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className className
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
...props return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
} }
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = "default", variant = 'default',
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: 'default' | 'destructive';
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -77,7 +64,7 @@ function DropdownMenuItem({
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@@ -103,18 +90,13 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return ( return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@@ -138,7 +120,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@@ -146,19 +128,16 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@@ -168,32 +147,24 @@ function DropdownMenuSeparator({
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
className,
...props
}: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
...props return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@@ -202,7 +173,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -217,7 +188,7 @@ function DropdownMenuSubTrigger({
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@@ -228,12 +199,12 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -252,4 +223,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };
+8 -8
View File
@@ -1,21 +1,21 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return ( return (
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };
+8 -11
View File
@@ -1,24 +1,21 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Label({ function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };
+8 -12
View File
@@ -1,24 +1,20 @@
import { useState } from 'react' import { useState } from 'react';
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button';
import { Eye, EyeOff } from 'lucide-react' import { Eye, EyeOff } from 'lucide-react';
interface MaskedInputProps extends React.InputHTMLAttributes<HTMLInputElement> { interface MaskedInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string label?: string;
} }
export function MaskedInput({ label, ...props }: MaskedInputProps) { export function MaskedInput({ label, ...props }: MaskedInputProps) {
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false);
return ( return (
<div className="space-y-1"> <div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>} {label && <label className="text-sm font-medium">{label}</label>}
<div className="relative"> <div className="relative">
<Input <Input type={visible ? 'text' : 'password'} className="pr-10 font-mono" {...props} />
type={visible ? 'text' : 'password'}
className="pr-10 font-mono"
{...props}
/>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -31,5 +27,5 @@ export function MaskedInput({ label, ...props }: MaskedInputProps) {
</Button> </Button>
</div> </div>
</div> </div>
) );
} }
+8 -8
View File
@@ -1,13 +1,13 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = 'horizontal',
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
@@ -17,12 +17,12 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };
+37 -46
View File
@@ -1,29 +1,23 @@
import * as React from "react" import * as React from 'react';
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from '@radix-ui/react-dialog';
import { XIcon } from "lucide-react" import { XIcon } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
...props return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
} }
function SheetClose({ function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
...props return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
} }
function SheetPortal({ function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
...props return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
} }
function SheetOverlay({ function SheetOverlay({
@@ -34,21 +28,21 @@ function SheetOverlay({
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
className, className,
children, children,
side = "right", side = 'right',
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: 'top' | 'right' | 'bottom' | 'left';
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@@ -56,15 +50,15 @@ function SheetContent({
<SheetPrimitive.Content <SheetPrimitive.Content
data-slot="sheet-content" data-slot="sheet-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === "right" && side === 'right' &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === "left" && side === 'left' &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === "top" && side === 'top' &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === "bottom" && side === 'bottom' &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className className
)} )}
{...props} {...props}
@@ -76,40 +70,37 @@ function SheetContent({
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="sheet-header" data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn('flex flex-col gap-1.5 p-4', className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="sheet-footer" data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return ( return (
<SheetPrimitive.Title <SheetPrimitive.Title
data-slot="sheet-title" data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)} className={cn('text-foreground font-semibold', className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@@ -119,10 +110,10 @@ function SheetDescription({
return ( return (
<SheetPrimitive.Description <SheetPrimitive.Description
data-slot="sheet-description" data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -134,4 +125,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };
+205 -258
View File
@@ -1,55 +1,30 @@
import * as React from "react" import * as React from 'react';
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { PanelLeftIcon } from "lucide-react" import { PanelLeftIcon } from 'lucide-react';
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from "@/lib/utils" import { useSidebar, SidebarContext, type SidebarContextProps } from '@/hooks/use-sidebar';
import { Button } from "@/components/ui/button" import { cn } from '@/lib/utils';
import { Input } from "@/components/ui/input" import { Button } from '@/components/ui/button';
import { Separator } from "@/components/ui/separator" import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from '@/components/ui/sheet';
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from '@/components/ui/skeleton';
import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = 'sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = "b" 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<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({ function SidebarProvider({
defaultOpen = true, defaultOpen = true,
@@ -59,57 +34,54 @@ function SidebarProvider({
style, style,
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<'div'> & {
defaultOpen?: boolean defaultOpen?: boolean;
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) { }) {
const isMobile = useIsMobile() const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) { if (setOpenProp) {
setOpenProp(openState) setOpenProp(openState);
} else { } else {
_setOpen(openState) _setOpen(openState);
} }
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open] [setOpenProp, open]
) );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]) }, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ( if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.preventDefault();
(event.metaKey || event.ctrlKey) toggleSidebar();
) {
event.preventDefault()
toggleSidebar()
} }
} };
window.addEventListener("keydown", handleKeyDown) window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]) }, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed". // 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. // This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed" const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContextProps>( const contextValue = React.useMemo<SidebarContextProps>(
() => ({ () => ({
@@ -122,7 +94,7 @@ function SidebarProvider({
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
) );
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
@@ -131,13 +103,13 @@ function SidebarProvider({
data-slot="sidebar-wrapper" data-slot="sidebar-wrapper"
style={ style={
{ {
"--sidebar-width": SIDEBAR_WIDTH, '--sidebar-width': SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style, ...style,
} as React.CSSProperties } as React.CSSProperties
} }
className={cn( className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className className
)} )}
{...props} {...props}
@@ -146,36 +118,36 @@ function SidebarProvider({
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} }
function Sidebar({ function Sidebar({
side = "left", side = 'left',
variant = "sidebar", variant = 'sidebar',
collapsible = "offcanvas", collapsible = 'offcanvas',
className, className,
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<'div'> & {
side?: "left" | "right" side?: 'left' | 'right';
variant?: "sidebar" | "floating" | "inset" variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: "offcanvas" | "icon" | "none" collapsible?: 'offcanvas' | 'icon' | 'none';
}) { }) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") { if (collapsible === 'none') {
return ( return (
<div <div
data-slot="sidebar" data-slot="sidebar"
className={cn( className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", 'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className className
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
) );
} }
if (isMobile) { if (isMobile) {
@@ -188,7 +160,7 @@ function Sidebar({
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={ style={
{ {
"--sidebar-width": SIDEBAR_WIDTH_MOBILE, '--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties } as React.CSSProperties
} }
side={side} side={side}
@@ -200,14 +172,14 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) );
} }
return ( return (
<div <div
className="group peer text-sidebar-foreground hidden md:block" className="group peer text-sidebar-foreground hidden md:block"
data-state={state} data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""} data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant} data-variant={variant}
data-side={side} data-side={side}
data-slot="sidebar" data-slot="sidebar"
@@ -216,25 +188,25 @@ function Sidebar({
<div <div
data-slot="sidebar-gap" data-slot="sidebar-gap"
className={cn( className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
"group-data-[collapsible=offcanvas]:w-0", 'group-data-[collapsible=offcanvas]:w-0',
"group-data-[side=right]:rotate-180", 'group-data-[side=right]:rotate-180',
variant === "floating" || variant === "inset" variant === 'floating' || variant === 'inset'
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)} )}
/> />
<div <div
data-slot="sidebar-container" data-slot="sidebar-container"
className={cn( className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === "left" side === 'left'
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants. // Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset" variant === 'floating' || variant === 'inset'
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className className
)} )}
{...props} {...props}
@@ -248,15 +220,11 @@ function Sidebar({
</div> </div>
</div> </div>
</div> </div>
) );
} }
function SidebarTrigger({ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
className, const { toggleSidebar } = useSidebar();
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return ( return (
<Button <Button
@@ -264,21 +232,21 @@ function SidebarTrigger({
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn("size-7", className)} className={cn('size-7', className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event);
toggleSidebar() toggleSidebar();
}} }}
{...props} {...props}
> >
<PanelLeftIcon /> <PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) );
} }
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<button <button
@@ -289,225 +257,216 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title="Toggle Sidebar"
className={cn( className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", 'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return ( return (
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( className={cn(
"bg-background relative flex w-full flex-1 flex-col", 'bg-background relative flex w-full flex-1 flex-col',
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInput({ function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
className,
...props
}: React.ComponentProps<typeof Input>) {
return ( return (
<Input <Input
data-slot="sidebar-input" data-slot="sidebar-input"
data-sidebar="input" data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)} className={cn('bg-background h-8 w-full shadow-none', className)}
{...props} {...props}
/> />
) );
} }
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="sidebar-header" data-slot="sidebar-header"
data-sidebar="header" data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)} className={cn('flex flex-col gap-2 p-2', className)}
{...props} {...props}
/> />
) );
} }
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="sidebar-footer" data-slot="sidebar-footer"
data-sidebar="footer" data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)} className={cn('flex flex-col gap-2 p-2', className)}
{...props} {...props}
/> />
) );
} }
function SidebarSeparator({ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
className,
...props
}: React.ComponentProps<typeof Separator>) {
return ( return (
<Separator <Separator
data-slot="sidebar-separator" data-slot="sidebar-separator"
data-sidebar="separator" data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)} className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props} {...props}
/> />
) );
} }
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="sidebar-group" data-slot="sidebar-group"
data-sidebar="group" data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)} className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props} {...props}
/> />
) );
} }
function SidebarGroupLabel({ function SidebarGroupLabel({
className, className,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) { }: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div" const Comp = asChild ? Slot : 'div';
return ( return (
<Comp <Comp
data-slot="sidebar-group-label" data-slot="sidebar-group-label"
data-sidebar="group-label" data-sidebar="group-label"
className={cn( className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", 'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupAction({ function SidebarGroupAction({
className, className,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) { }: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
data-slot="sidebar-group-action" data-slot="sidebar-group-action"
data-sidebar="group-action" data-sidebar="group-action"
className={cn( className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden", 'after:absolute after:-inset-2 md:after:hidden',
"group-data-[collapsible=icon]:hidden", 'group-data-[collapsible=icon]:hidden',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupContent({ function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-group-content" data-slot="sidebar-group-content"
data-sidebar="group-content" data-sidebar="group-content"
className={cn("w-full text-sm", className)} className={cn('w-full text-sm', className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return ( return (
<ul <ul
data-slot="sidebar-menu" data-slot="sidebar-menu"
data-sidebar="menu" data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)} className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return ( return (
<li <li
data-slot="sidebar-menu-item" data-slot="sidebar-menu-item"
data-sidebar="menu-item" data-sidebar="menu-item"
className={cn("group/menu-item relative", className)} className={cn('group/menu-item relative', className)}
{...props} {...props}
/> />
) );
} }
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline: outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", 'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
}, },
size: { size: {
default: "h-8 text-sm", default: 'h-8 text-sm',
sm: "h-7 text-xs", sm: 'h-7 text-xs',
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} }
) );
function SidebarMenuButton({ function SidebarMenuButton({
asChild = false, asChild = false,
isActive = false, isActive = false,
variant = "default", variant = 'default',
size = "default", size = 'default',
tooltip, tooltip,
className, className,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<'button'> & {
asChild?: boolean asChild?: boolean;
isActive?: boolean isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) { } & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const button = ( const button = (
<Comp <Comp
@@ -518,16 +477,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} {...props}
/> />
) );
if (!tooltip) { if (!tooltip) {
return button return button;
} }
if (typeof tooltip === "string") { if (typeof tooltip === 'string') {
tooltip = { tooltip = {
children: tooltip, children: tooltip,
} };
} }
return ( return (
@@ -536,11 +495,11 @@ function SidebarMenuButton({
<TooltipContent <TooltipContent
side="right" side="right"
align="center" align="center"
hidden={state !== "collapsed" || isMobile} hidden={state !== 'collapsed' || isMobile}
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
) );
} }
function SidebarMenuAction({ function SidebarMenuAction({
@@ -548,134 +507,123 @@ function SidebarMenuAction({
asChild = false, asChild = false,
showOnHover = false, showOnHover = false,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<'button'> & {
asChild?: boolean asChild?: boolean;
showOnHover?: boolean showOnHover?: boolean;
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
data-slot="sidebar-menu-action" data-slot="sidebar-menu-action"
data-sidebar="menu-action" data-sidebar="menu-action"
className={cn( className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden", 'after:absolute after:-inset-2 md:after:hidden',
"peer-data-[size=sm]/menu-button:top-1", 'peer-data-[size=sm]/menu-button:top-1',
"peer-data-[size=default]/menu-button:top-1.5", 'peer-data-[size=default]/menu-button:top-1.5',
"peer-data-[size=lg]/menu-button:top-2.5", 'peer-data-[size=lg]/menu-button:top-2.5',
"group-data-[collapsible=icon]:hidden", 'group-data-[collapsible=icon]:hidden',
showOnHover && showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuBadge({ function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-menu-badge" data-slot="sidebar-menu-badge"
data-sidebar="menu-badge" data-sidebar="menu-badge"
className={cn( className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none", 'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
"peer-data-[size=sm]/menu-button:top-1", 'peer-data-[size=sm]/menu-button:top-1',
"peer-data-[size=default]/menu-button:top-1.5", 'peer-data-[size=default]/menu-button:top-1.5',
"peer-data-[size=lg]/menu-button:top-2.5", 'peer-data-[size=lg]/menu-button:top-2.5',
"group-data-[collapsible=icon]:hidden", 'group-data-[collapsible=icon]:hidden',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSkeleton({ function SidebarMenuSkeleton({
className, className,
showIcon = false, showIcon = false,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<'div'> & {
showIcon?: boolean showIcon?: boolean;
}) { }) {
// Random width between 50 to 90%. // Fixed widths array for consistent skeleton loading
const width = React.useMemo(() => { const widths = [70, 60, 80, 50];
return `${Math.floor(Math.random() * 40) + 50}%` const id = React.useId();
}, []) const width = widths[parseInt(id.slice(-1), 16) % widths.length];
return ( return (
<div <div
data-slot="sidebar-menu-skeleton" data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton" data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props} {...props}
> >
{showIcon && ( {showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton <Skeleton
className="h-4 max-w-(--skeleton-width) flex-1" className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text" data-sidebar="menu-skeleton-text"
style={ style={
{ {
"--skeleton-width": width, '--skeleton-width': `${width}%`,
} as React.CSSProperties } as React.CSSProperties
} }
/> />
</div> </div>
) );
} }
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return ( return (
<ul <ul
data-slot="sidebar-menu-sub" data-slot="sidebar-menu-sub"
data-sidebar="menu-sub" data-sidebar="menu-sub"
className={cn( className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", 'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
"group-data-[collapsible=icon]:hidden", 'group-data-[collapsible=icon]:hidden',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubItem({ function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
className,
...props
}: React.ComponentProps<"li">) {
return ( return (
<li <li
data-slot="sidebar-menu-sub-item" data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item" data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)} className={cn('group/menu-sub-item relative', className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubButton({ function SidebarMenuSubButton({
asChild = false, asChild = false,
size = "md", size = 'md',
isActive = false, isActive = false,
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<'a'> & {
asChild?: boolean asChild?: boolean;
size?: "sm" | "md" size?: 'sm' | 'md';
isActive?: boolean isActive?: boolean;
}) { }) {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : 'a';
return ( return (
<Comp <Comp
@@ -684,16 +632,16 @@ function SidebarMenuSubButton({
data-size={size} data-size={size}
data-active={isActive} data-active={isActive}
className={cn( className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === "sm" && "text-xs", size === 'sm' && 'text-xs',
size === "md" && "text-sm", size === 'md' && 'text-sm',
"group-data-[collapsible=icon]:hidden", 'group-data-[collapsible=icon]:hidden',
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -720,5 +668,4 @@ export {
SidebarRail, SidebarRail,
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar, };
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="skeleton" data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)} className={cn('bg-accent animate-pulse rounded-md', className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };
+27 -51
View File
@@ -1,114 +1,90 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<'table'>) {
return ( return (
<div <div data-slot="table-container" className="relative w-full overflow-x-auto">
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table <table
data-slot="table" data-slot="table"
className={cn("w-full caption-bottom text-sm", className)} className={cn('w-full caption-bottom text-sm', className)}
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return ( return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />;
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return ( return (
<tbody <tbody
data-slot="table-body" data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)} className={cn('[&_tr:last-child]:border-0', className)}
{...props} {...props}
/> />
) );
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return ( return (
<tfoot <tfoot
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return ( return (
<tr <tr
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return ( return (
<th <th
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", 'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return ( return (
<td <td
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", 'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
className,
...props
}: React.ComponentProps<"caption">) {
return ( return (
<caption <caption
data-slot="table-caption" data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props} {...props}
/> />
) );
} }
export { export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+12 -16
View File
@@ -1,9 +1,9 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@@ -15,23 +15,19 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return ( return (
<TooltipProvider> <TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> <TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider> </TooltipProvider>
) );
} }
function TooltipTrigger({ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
...props return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
} }
function TooltipContent({ function TooltipContent({
@@ -46,7 +42,7 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", 'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className className
)} )}
{...props} {...props}
@@ -55,7 +51,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+15
View File
@@ -0,0 +1,15 @@
/**
* WebSocket Context (Phase 04)
*/
import { createContext } from 'react';
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
export interface WSContextValue {
status: ConnectionStatus;
connect: () => void;
disconnect: () => void;
}
export const WSContext = createContext<WSContextValue | null>(null);
+27 -27
View File
@@ -1,53 +1,53 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner' import { toast } from 'sonner';
interface HealthCheck { interface HealthCheck {
id: string id: string;
name: string name: string;
status: 'ok' | 'warning' | 'error' status: 'ok' | 'warning' | 'error';
message: string message: string;
details?: string details?: string;
fixable?: boolean fixable?: boolean;
} }
interface HealthReport { interface HealthReport {
timestamp: number timestamp: number;
checks: HealthCheck[] checks: HealthCheck[];
summary: { summary: {
total: number total: number;
passed: number passed: number;
warnings: number warnings: number;
errors: number errors: number;
} };
} }
export function useHealth() { export function useHealth() {
return useQuery<HealthReport>({ return useQuery<HealthReport>({
queryKey: ['health'], queryKey: ['health'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/api/health') const res = await fetch('/api/health');
return res.json() return res.json();
}, },
refetchInterval: 30000, // Auto-refresh every 30s refetchInterval: 30000, // Auto-refresh every 30s
}) });
} }
export function useFixHealth() { export function useFixHealth() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (checkId: string) => { mutationFn: async (checkId: string) => {
const res = await fetch(`/api/health/fix/${checkId}`, { method: 'POST' }) const res = await fetch(`/api/health/fix/${checkId}`, { method: 'POST' });
const data = await res.json() const data = await res.json();
if (!res.ok) throw new Error(data.message) if (!res.ok) throw new Error(data.message);
return data return data;
}, },
onSuccess: (data: { message: string }) => { onSuccess: (data: { message: string }) => {
queryClient.invalidateQueries({ queryKey: ['health'] }) queryClient.invalidateQueries({ queryKey: ['health'] });
toast.success(data.message) toast.success(data.message);
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message) toast.error(error.message);
}, },
}) });
} }
+11 -11
View File
@@ -1,19 +1,19 @@
import * as React from "react" import * as React from 'react';
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
} };
mql.addEventListener("change", onChange) mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener('change', onChange);
}, []) }, []);
return !!isMobile return !!isMobile;
} }
+11 -11
View File
@@ -1,22 +1,22 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
interface Overview { interface Overview {
profiles: number profiles: number;
cliproxy: number cliproxy: number;
accounts: number accounts: number;
health: { health: {
status: 'ok' | 'warning' | 'error' status: 'ok' | 'warning' | 'error';
passed: number passed: number;
total: number total: number;
} };
} }
export function useOverview() { export function useOverview() {
return useQuery<Overview>({ return useQuery<Overview>({
queryKey: ['overview'], queryKey: ['overview'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/api/overview') const res = await fetch('/api/overview');
return res.json() return res.json();
}, },
}) });
} }
+16 -16
View File
@@ -1,36 +1,36 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
interface SharedItem { interface SharedItem {
name: string name: string;
description: string description: string;
path: string path: string;
type: 'command' | 'skill' | 'agent' type: 'command' | 'skill' | 'agent';
} }
interface SharedSummary { interface SharedSummary {
commands: number commands: number;
skills: number skills: number;
agents: number agents: number;
total: number total: number;
symlinkStatus: { valid: boolean; message: string } symlinkStatus: { valid: boolean; message: string };
} }
export function useSharedSummary() { export function useSharedSummary() {
return useQuery<SharedSummary>({ return useQuery<SharedSummary>({
queryKey: ['shared', 'summary'], queryKey: ['shared', 'summary'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/api/shared/summary') const res = await fetch('/api/shared/summary');
return res.json() return res.json();
}, },
}) });
} }
export function useSharedItems(type: 'commands' | 'skills' | 'agents') { export function useSharedItems(type: 'commands' | 'skills' | 'agents') {
return useQuery<{ items: SharedItem[] }>({ return useQuery<{ items: SharedItem[] }>({
queryKey: ['shared', type], queryKey: ['shared', type],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/api/shared/${type}`) const res = await fetch(`/api/shared/${type}`);
return res.json() return res.json();
}, },
}) });
} }
+24
View File
@@ -0,0 +1,24 @@
import * as React from 'react';
export 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<SidebarContextProps | null>(null);
export function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context;
}
export { SidebarContext };
+10 -9
View File
@@ -1,22 +1,23 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
function getInitialTheme() {
if (typeof window === 'undefined') return false;
const stored = localStorage.getItem('ccs-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return stored === 'dark' || (!stored && prefersDark);
}
export function useTheme() { export function useTheme() {
const [isDark, setIsDark] = useState(false); const [isDark, setIsDark] = useState(getInitialTheme);
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem('ccs-theme'); document.documentElement.classList.toggle('dark', isDark);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; }, [isDark]);
const isDarkMode = stored === 'dark' || (!stored && prefersDark);
setIsDark(isDarkMode);
document.documentElement.classList.toggle('dark', isDarkMode);
}, []);
const toggle = () => { const toggle = () => {
const newValue = !isDark; const newValue = !isDark;
setIsDark(newValue); setIsDark(newValue);
localStorage.setItem('ccs-theme', newValue ? 'dark' : 'light'); localStorage.setItem('ccs-theme', newValue ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', newValue);
}; };
return { isDark, toggle }; return { isDark, toggle };
+10
View File
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { WSContext } from '@/contexts/ws-context';
export function useWebSocketContext() {
const context = useContext(WSContext);
if (!context) {
throw new Error('useWebSocketContext must be used within WebSocketProvider');
}
return context;
}
+51 -35
View File
@@ -22,6 +22,42 @@ export function useWebSocket() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const reconnectAttempts = useRef(0); const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 5; const maxReconnectAttempts = 5;
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const connectRef = useRef<() => void>(() => {});
const handleMessage = useCallback(
(message: WSMessage) => {
switch (message.type) {
case 'connected':
console.log('[WS] Server acknowledged connection');
break;
case 'config-changed':
queryClient.invalidateQueries({ queryKey: ['profiles'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
toast.info('Configuration updated externally');
break;
case 'settings-changed':
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.info('Settings file updated');
break;
case 'profiles-changed':
queryClient.invalidateQueries({ queryKey: ['accounts'] });
toast.info('Accounts updated');
break;
case 'pong':
// Heartbeat response
break;
default:
console.log(`[WS] Unknown message: ${message.type}`);
}
},
[queryClient]
);
const connect = useCallback(() => { const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
@@ -56,56 +92,36 @@ export function useWebSocket() {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
reconnectAttempts.current++; reconnectAttempts.current++;
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`); console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`);
setTimeout(connect, delay); // Use ref to avoid stale closure
reconnectTimeoutRef.current = setTimeout(() => {
connectRef.current();
}, delay);
} }
}; };
ws.onerror = () => { ws.onerror = () => {
console.log('[WS] Connection error'); console.log('[WS] Connection error');
}; };
}, []); }, [handleMessage]);
const handleMessage = (message: WSMessage) => { // Keep ref in sync
switch (message.type) { connectRef.current = connect;
case 'connected':
console.log('[WS] Server acknowledged connection');
break;
case 'config-changed':
queryClient.invalidateQueries({ queryKey: ['profiles'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
toast.info('Configuration updated externally');
break;
case 'settings-changed':
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.info('Settings file updated');
break;
case 'profiles-changed':
queryClient.invalidateQueries({ queryKey: ['accounts'] });
toast.info('Accounts updated');
break;
case 'pong':
// Heartbeat response
break;
default:
console.log(`[WS] Unknown message: ${message.type}`);
}
};
const disconnect = useCallback(() => { const disconnect = useCallback(() => {
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnect reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnect
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
wsRef.current?.close(); wsRef.current?.close();
}, []); }, []);
// Connect on mount, disconnect on unmount // Initial connection - use ref to satisfy linter
useEffect(() => { useEffect(() => {
connect(); connectRef.current();
return () => disconnect(); return () => disconnect();
}, [connect, disconnect]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Heartbeat to keep connection alive // Heartbeat to keep connection alive
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "tailwindcss"; @import 'tailwindcss';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
+11 -7
View File
@@ -1,10 +1,14 @@
import { StrictMode } from 'react' import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import './index.css' import './index.css';
import App from './App.tsx' import App from './App.tsx';
createRoot(document.getElementById('root')!).render( const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found. Failed to mount React application.');
}
createRoot(rootElement).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>
) );
+1 -5
View File
@@ -47,11 +47,7 @@ export function ApiPage() {
<ProfilesTable data={data?.profiles || []} onEdit={handleEdit} /> <ProfilesTable data={data?.profiles || []} onEdit={handleEdit} />
)} )}
<ProfileDialog <ProfileDialog open={dialogOpen} onClose={handleCloseDialog} profile={editingProfile} />
open={dialogOpen}
onClose={handleCloseDialog}
profile={editingProfile}
/>
</div> </div>
); );
} }
+11 -19
View File
@@ -1,14 +1,14 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react';
import { HealthCard } from '@/components/health-card' import { HealthCard } from '@/components/health-card';
import { useHealth } from '@/hooks/use-health' import { useHealth } from '@/hooks/use-health';
export function HealthPage() { export function HealthPage() {
const { data, isLoading, refetch, dataUpdatedAt } = useHealth() const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
const formatTime = (timestamp: number) => { const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString() return new Date(timestamp).toLocaleTimeString();
} };
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@@ -16,16 +16,10 @@ export function HealthPage() {
<div> <div>
<h1 className="text-2xl font-bold">Health Dashboard</h1> <h1 className="text-2xl font-bold">Health Dashboard</h1>
{dataUpdatedAt && ( {dataUpdatedAt && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Last check: {formatTime(dataUpdatedAt)}</p>
Last check: {formatTime(dataUpdatedAt)}
</p>
)} )}
</div> </div>
<Button <Button variant="outline" onClick={() => refetch()} disabled={isLoading}>
variant="outline"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Refresh Refresh
</Button> </Button>
@@ -48,9 +42,7 @@ export function HealthPage() {
</div> </div>
)} )}
{isLoading && !data && ( {isLoading && !data && <div className="text-muted-foreground">Running health checks...</div>}
<div className="text-muted-foreground">Running health checks...</div>
)}
{data && ( {data && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -60,5 +52,5 @@ export function HealthPage() {
</div> </div>
)} )}
</div> </div>
) );
} }
+21 -17
View File
@@ -1,29 +1,27 @@
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { StatCard } from '@/components/stat-card' import { StatCard } from '@/components/stat-card';
import { Key, Zap, Users, Activity, Plus, Stethoscope, BookOpen, FolderOpen } from 'lucide-react' import { Key, Zap, Users, Activity, Plus, Stethoscope, BookOpen, FolderOpen } from 'lucide-react';
import { useOverview } from '@/hooks/use-overview' import { useOverview } from '@/hooks/use-overview';
import { useSharedSummary } from '@/hooks/use-shared' import { useSharedSummary } from '@/hooks/use-shared';
export function HomePage() { export function HomePage() {
const navigate = useNavigate() const navigate = useNavigate();
const { data: overview } = useOverview() const { data: overview } = useOverview();
const { data: shared } = useSharedSummary() const { data: shared } = useSharedSummary();
const healthColor = { const healthColor = {
ok: 'text-green-500', ok: 'text-green-500',
warning: 'text-yellow-500', warning: 'text-yellow-500',
error: 'text-red-500', error: 'text-red-500',
} };
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div> <div>
<h1 className="text-2xl font-bold">Welcome to CCS Config</h1> <h1 className="text-2xl font-bold">Welcome to CCS Config</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">Manage your Claude Code Switch configuration</p>
Manage your Claude Code Switch configuration
</p>
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
@@ -68,7 +66,11 @@ export function HomePage() {
<Stethoscope className="w-4 h-4 mr-2" /> Run Doctor <Stethoscope className="w-4 h-4 mr-2" /> Run Doctor
</Button> </Button>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href="https://github.com/anthropics/claude-cli" target="_blank" rel="noopener noreferrer"> <a
href="https://github.com/anthropics/claude-cli"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className="w-4 h-4 mr-2" /> Documentation <BookOpen className="w-4 h-4 mr-2" /> Documentation
</a> </a>
</Button> </Button>
@@ -100,12 +102,14 @@ export function HomePage() {
</div> </div>
</div> </div>
{shared?.symlinkStatus && ( {shared?.symlinkStatus && (
<p className={`text-xs mt-2 ${shared.symlinkStatus.valid ? 'text-green-600' : 'text-yellow-600'}`}> <p
className={`text-xs mt-2 ${shared.symlinkStatus.valid ? 'text-green-600' : 'text-yellow-600'}`}
>
{shared.symlinkStatus.message} {shared.symlinkStatus.message}
</p> </p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }
+74 -64
View File
@@ -1,54 +1,52 @@
import { useState, useEffect } from 'react' import { useState, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label';
import { MaskedInput } from '@/components/ui/masked-input' import { MaskedInput } from '@/components/ui/masked-input';
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog';
import { Edit, Save, X } from 'lucide-react' import { Edit, Save, X } from 'lucide-react';
import { toast } from 'sonner' import { toast } from 'sonner';
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client';
interface Settings { interface Settings {
env?: Record<string, string> env?: Record<string, string>;
} }
interface SettingsResponse { interface SettingsResponse {
profile: string profile: string;
settings: Settings settings: Settings;
mtime: number mtime: number;
path: string path: string;
} }
export function SettingsPage() { export function SettingsPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams();
const profile = searchParams.get('profile') const profile = searchParams.get('profile');
const [editMode, setEditMode] = useState(false) const [editMode, setEditMode] = useState(false);
const [editedSettings, setEditedSettings] = useState<Settings | null>(null) const [editedSettings, setEditedSettings] = useState<Settings | null>(null);
const [conflictDialog, setConflictDialog] = useState(false) const [conflictDialog, setConflictDialog] = useState(false);
const queryClient = useQueryClient() const queryClient = useQueryClient();
// Fetch profiles for selector // Fetch profiles for selector
const { data: profilesData } = useQuery({ const { data: profilesData } = useQuery({
queryKey: ['profiles'], queryKey: ['profiles'],
queryFn: () => api.profiles.list(), queryFn: () => api.profiles.list(),
}) });
// Fetch settings for selected profile // Fetch settings for selected profile
const { data, isLoading, refetch } = useQuery<SettingsResponse>({ const { data, isLoading, refetch } = useQuery<SettingsResponse>({
queryKey: ['settings', profile], queryKey: ['settings', profile],
queryFn: () => fetch(`/api/settings/${profile}/raw`).then(r => r.json()), queryFn: () => fetch(`/api/settings/${profile}/raw`).then((r) => r.json()),
enabled: !!profile, enabled: !!profile,
}) });
// Initialize edited settings when data loads // Use edited settings when in edit mode, otherwise use data directly
useEffect(() => { const currentSettings = useMemo(() => {
if (data?.settings) { return editMode && editedSettings ? editedSettings : data?.settings;
setEditedSettings(data.settings) }, [editMode, editedSettings, data?.settings]);
}
}, [data])
// Save mutation // Save mutation
const saveMutation = useMutation({ const saveMutation = useMutation({
@@ -60,50 +58,49 @@ export function SettingsPage() {
settings: editedSettings, settings: editedSettings,
expectedMtime: data?.mtime, expectedMtime: data?.mtime,
}), }),
}) });
if (res.status === 409) { if (res.status === 409) {
throw new Error('CONFLICT') throw new Error('CONFLICT');
} }
if (!res.ok) { if (!res.ok) {
throw new Error('Failed to save') throw new Error('Failed to save');
} }
return res.json() return res.json();
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', profile] }) queryClient.invalidateQueries({ queryKey: ['settings', profile] });
setEditMode(false) setEditedSettings(null);
toast.success('Settings saved') setEditMode(false);
toast.success('Settings saved');
}, },
onError: (error: Error) => { onError: (error: Error) => {
if (error.message === 'CONFLICT') { if (error.message === 'CONFLICT') {
setConflictDialog(true) setConflictDialog(true);
} else { } else {
toast.error(error.message) toast.error(error.message);
} }
}, },
}) });
const handleSave = () => { const handleSave = () => {
saveMutation.mutate() saveMutation.mutate();
} };
const handleConflictResolve = async (overwrite: boolean) => { const handleConflictResolve = async (overwrite: boolean) => {
setConflictDialog(false) setConflictDialog(false);
if (overwrite) { if (overwrite) {
// Refetch to get new mtime, then save // Refetch to get new mtime, then save
await refetch() await refetch();
saveMutation.mutate() saveMutation.mutate();
} else { } else {
// Discard local changes // Discard local changes
if (data?.settings) { setEditedSettings(null);
setEditedSettings(data.settings) setEditMode(false);
}
setEditMode(false)
} }
} };
const updateEnvValue = (key: string, value: string) => { const updateEnvValue = (key: string, value: string) => {
setEditedSettings((prev) => ({ setEditedSettings((prev) => ({
@@ -112,8 +109,8 @@ export function SettingsPage() {
...prev?.env, ...prev?.env,
[key]: value, [key]: value,
}, },
})) }));
} };
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@@ -122,11 +119,15 @@ export function SettingsPage() {
<select <select
className="border rounded px-3 py-2" className="border rounded px-3 py-2"
value={profile || ''} value={profile || ''}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSearchParams({ profile: e.target.value })} onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setSearchParams({ profile: e.target.value })
}
> >
<option value="">Select profile...</option> <option value="">Select profile...</option>
{profilesData?.profiles.map((p) => ( {profilesData?.profiles.map((p) => (
<option key={p.name} value={p.name}>{p.name}</option> <option key={p.name} value={p.name}>
{p.name}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -147,21 +148,30 @@ export function SettingsPage() {
</Card> </Card>
)} )}
{profile && data && editedSettings && ( {profile && data && currentSettings && (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Environment Variables</CardTitle> <CardTitle>Environment Variables</CardTitle>
<div className="flex gap-2"> <div className="flex gap-2">
{!editMode ? ( {!editMode ? (
<Button variant="outline" onClick={() => setEditMode(true)}> <Button
variant="outline"
onClick={() => {
setEditedSettings(data.settings);
setEditMode(true);
}}
>
<Edit className="w-4 h-4 mr-2" /> Edit <Edit className="w-4 h-4 mr-2" /> Edit
</Button> </Button>
) : ( ) : (
<> <>
<Button variant="outline" onClick={() => { <Button
setEditedSettings(data.settings) variant="outline"
setEditMode(false) onClick={() => {
}}> setEditedSettings(null);
setEditMode(false);
}}
>
<X className="w-4 h-4 mr-2" /> Cancel <X className="w-4 h-4 mr-2" /> Cancel
</Button> </Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}> <Button onClick={handleSave} disabled={saveMutation.isPending}>
@@ -173,7 +183,7 @@ export function SettingsPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{Object.entries(editedSettings.env || {}).map(([key, value]) => ( {Object.entries(currentSettings.env || {}).map(([key, value]) => (
<div key={key}> <div key={key}>
<Label>{key}</Label> <Label>{key}</Label>
{key.includes('TOKEN') || key.includes('KEY') ? ( {key.includes('TOKEN') || key.includes('KEY') ? (
@@ -211,5 +221,5 @@ export function SettingsPage() {
onCancel={() => handleConflictResolve(false)} onCancel={() => handleConflictResolve(false)}
/> />
</div> </div>
) );
} }
+12 -14
View File
@@ -1,21 +1,21 @@
import { useState } from 'react' import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button';
import { useSharedItems, useSharedSummary } from '@/hooks/use-shared' import { useSharedItems, useSharedSummary } from '@/hooks/use-shared';
import { FileText, Sparkles, Bot, AlertTriangle } from 'lucide-react' import { FileText, Sparkles, Bot, AlertTriangle } from 'lucide-react';
type TabType = 'commands' | 'skills' | 'agents' type TabType = 'commands' | 'skills' | 'agents';
export function SharedPage() { export function SharedPage() {
const [tab, setTab] = useState<TabType>('commands') const [tab, setTab] = useState<TabType>('commands');
const { data: summary } = useSharedSummary() const { data: summary } = useSharedSummary();
const { data: items, isLoading } = useSharedItems(tab) const { data: items, isLoading } = useSharedItems(tab);
const tabs: { id: TabType; label: string; icon: typeof FileText; count: number }[] = [ const tabs: { id: TabType; label: string; icon: typeof FileText; count: number }[] = [
{ id: 'commands', label: 'Commands', icon: FileText, count: summary?.commands ?? 0 }, { id: 'commands', label: 'Commands', icon: FileText, count: summary?.commands ?? 0 },
{ id: 'skills', label: 'Skills', icon: Sparkles, count: summary?.skills ?? 0 }, { id: 'skills', label: 'Skills', icon: Sparkles, count: summary?.skills ?? 0 },
{ id: 'agents', label: 'Agents', icon: Bot, count: summary?.agents ?? 0 }, { id: 'agents', label: 'Agents', icon: Bot, count: summary?.agents ?? 0 },
] ];
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@@ -65,9 +65,7 @@ export function SharedPage() {
<Card key={item.name}> <Card key={item.name}>
<CardContent className="pt-4"> <CardContent className="pt-4">
<div className="font-medium">{item.name}</div> <div className="font-medium">{item.name}</div>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">{item.description}</p>
{item.description}
</p>
<p className="text-xs text-muted-foreground mt-2 font-mono truncate"> <p className="text-xs text-muted-foreground mt-2 font-mono truncate">
{item.path} {item.path}
</p> </p>
@@ -78,5 +76,5 @@ export function SharedPage() {
)} )}
</div> </div>
</div> </div>
) );
} }
+2 -19
View File
@@ -4,29 +4,12 @@
* React context provider for WebSocket connection. * React context provider for WebSocket connection.
*/ */
import { createContext, useContext, type ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useWebSocket } from '@/hooks/use-websocket'; import { useWebSocket } from '@/hooks/use-websocket';
import { WSContext } from '@/contexts/ws-context';
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
interface WSContextValue {
status: ConnectionStatus;
connect: () => void;
disconnect: () => void;
}
const WSContext = createContext<WSContextValue | null>(null);
export function WebSocketProvider({ children }: { children: ReactNode }) { export function WebSocketProvider({ children }: { children: ReactNode }) {
const ws = useWebSocket(); const ws = useWebSocket();
return <WSContext.Provider value={ws}>{children}</WSContext.Provider>; return <WSContext.Provider value={ws}>{children}</WSContext.Provider>;
} }
export function useWSContext() {
const context = useContext(WSContext);
if (!context) {
throw new Error('useWSContext must be used within WebSocketProvider');
}
return context;
}