diff --git a/Makefile b/Makefile index cb55f93..466bb8f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=nasctl -VERSION?=0.8.3 +VERSION?=1.0.0 GO?=go LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_FLAGS=CGO_ENABLED=0 diff --git a/web/index.html b/web/index.html index a97fc3d..6c372e8 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,26 @@ nasctl + + +
diff --git a/web/src/App.tsx b/web/src/App.tsx index c1ed771..ce16504 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import Log from "./pages/Log"; import Settings from "./pages/Settings"; import Files from "./pages/Files"; import Storage from "./pages/Storage"; +import NotFound from "./pages/NotFound"; type AuthState = { loading: boolean; authenticated: boolean; username: string }; @@ -26,7 +27,16 @@ export default function App() { }, []); if (auth.loading) { - return
Cargando...
; + return ( +
+
+ + + + Cargando… +
+
+ ); } if (!auth.authenticated) { @@ -60,7 +70,7 @@ export default function App() { } /> } /> } /> - } /> + } /> diff --git a/web/src/components/DirtyBanner.tsx b/web/src/components/DirtyBanner.tsx index c4aa77e..55e18e6 100644 --- a/web/src/components/DirtyBanner.tsx +++ b/web/src/components/DirtyBanner.tsx @@ -1,27 +1,35 @@ +import { AlertTriangle } from "../lib/icons"; import { useDirty } from "../DirtyContext"; +import { Button } from "./ui/Button"; -export default function DirtyBanner() { +export function DirtyBanner() { const { modules, apply, applying, error } = useDirty(); if (modules.length === 0 && !error) return null; return ( -
+
{modules.length > 0 && ( -
- - Tienes cambios sin aplicar:{" "} - - {modules.map((m) => m.module).join(", ")} - - - +
+
+ +
+ + Tienes cambios sin aplicar: + + + {modules.map((m) => m.module).join(", ")} + +
+
+
)} {error && ( -
- Error al aplicar: {error} +
+ + Error al aplicar: {error}
)}
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 3c44d1d..5872471 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,71 +1,27 @@ -import { useEffect, useState } from "react"; -import { NavLink, Outlet, useNavigate } from "react-router-dom"; -import { api, VersionInfo } from "../api"; -import DirtyBanner from "./DirtyBanner"; +import { Outlet } from "react-router-dom"; +import { Sidebar } from "./Sidebar"; +import { Topbar } from "./Topbar"; +import { DirtyBanner } from "./DirtyBanner"; -const navItems = [ - { to: "/", label: "Dashboard", end: true }, - { to: "/users", label: "Usuarios" }, - { to: "/files", label: "Archivos" }, - { to: "/samba", label: "SMB / Samba" }, - { to: "/nfs", label: "NFS" }, - { to: "/storage", label: "Almacenamiento" }, - { to: "/log", label: "Historial" }, - { to: "/settings", label: "Ajustes" }, -]; - -export default function Layout({ username, onLogout }: { username: string; onLogout: () => void }) { - const navigate = useNavigate(); - const [version, setVersion] = useState(null); - - useEffect(() => { - api.version().then(setVersion).catch(() => {}); - }, []); - - async function handleLogout() { - await api.logout(); - onLogout(); - navigate("/login"); - } +interface LayoutProps { + username: string; + onLogout: () => void; +} +export default function Layout({ username, onLogout }: LayoutProps) { return ( -
- -
+
+
+ +
+ +
+ -
+
-
-
+ +
); } diff --git a/web/src/components/PageHeader.tsx b/web/src/components/PageHeader.tsx new file mode 100644 index 0000000..0e84674 --- /dev/null +++ b/web/src/components/PageHeader.tsx @@ -0,0 +1,23 @@ +import { ReactNode } from "react"; +import { cn } from "../lib/cn"; + +interface PageHeaderProps { + title: string; + description?: string; + actions?: ReactNode; + className?: string; +} + +export function PageHeader({ title, description, actions, className }: PageHeaderProps) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..3cc07ef --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -0,0 +1,130 @@ +import { NavLink } from "react-router-dom"; +import { api } from "../api"; +import { ThemeToggle } from "./ThemeToggle"; +import { UserMenu } from "./UserMenu"; +import { VersionInfo } from "../api"; +import { + Home, + Users, + FolderTree, + Share2, + Network, + HardDrive, + History, + Settings, +} from "../lib/icons"; +import { cn } from "../lib/cn"; +import { useEffect, useState } from "react"; + +const navItems = [ + { to: "/", label: "Dashboard", end: true, Icon: Home }, + { to: "/users", label: "Usuarios", Icon: Users }, + { to: "/files", label: "Archivos", Icon: FolderTree }, + { to: "/samba", label: "SMB / Samba", Icon: Share2 }, + { to: "/nfs", label: "NFS", Icon: Network }, + { to: "/storage", label: "Almacenamiento", Icon: HardDrive }, + { to: "/log", label: "Historial", Icon: History }, + { to: "/settings", label: "Ajustes", Icon: Settings }, +]; + +interface SidebarProps { + username: string; + onLogout: () => void; + onClose?: () => void; + variant?: "drawer" | "fixed"; +} + +export function Sidebar({ username, onLogout, onClose, variant = "fixed" }: SidebarProps) { + const [version, setVersion] = useState(null); + + useEffect(() => { + api.version().then(setVersion).catch(() => {}); + }, []); + + return ( +
+
+
+
+ + + + + + +
+
+ nas + ctl +
+
+ {variant === "drawer" && onClose && ( + + )} +
+ + + +
+
+ +
+ +
+
+ v{version?.version ?? "dev"} + {version?.commit && ( + {version.commit} + )} +
+
+
+
+ ); +} diff --git a/web/src/components/ThemeProvider.tsx b/web/src/components/ThemeProvider.tsx new file mode 100644 index 0000000..188b98c --- /dev/null +++ b/web/src/components/ThemeProvider.tsx @@ -0,0 +1,50 @@ +import { createContext, useContext, useEffect, useState, ReactNode } from "react"; + +type Theme = "dark" | "light"; + +interface ThemeContextValue { + theme: Theme; + setTheme: (theme: Theme) => void; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState("dark"); + + useEffect(() => { + const stored = localStorage.getItem("nasctl-theme") as Theme | null; + if (stored === "dark" || stored === "light") { + setThemeState(stored); + } else { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + setThemeState(prefersDark ? "dark" : "light"); + } + }, []); + + useEffect(() => { + const root = document.documentElement; + if (theme === "light") { + root.classList.add("light"); + } else { + root.classList.remove("light"); + } + localStorage.setItem("nasctl-theme", theme); + }, [theme]); + + const setTheme = (t: Theme) => setThemeState(t); + const toggleTheme = () => setThemeState((prev) => (prev === "dark" ? "light" : "dark")); + + return ( + + {children} + + ); +} + +export function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); + return ctx; +} diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..4f0d63a --- /dev/null +++ b/web/src/components/ThemeToggle.tsx @@ -0,0 +1,16 @@ +import { useTheme } from "./ThemeProvider"; +import { Sun, Moon } from "../lib/icons"; + +export function ThemeToggle({ size = 16 }: { size?: number }) { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +} diff --git a/web/src/components/Topbar.tsx b/web/src/components/Topbar.tsx new file mode 100644 index 0000000..cd810b6 --- /dev/null +++ b/web/src/components/Topbar.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { Menu } from "../lib/icons"; +import { Sidebar } from "./Sidebar"; +import { useLocation } from "react-router-dom"; + +const pageTitles: Record = { + "/": "Dashboard", + "/users": "Usuarios", + "/files": "Archivos", + "/samba": "SMB / Samba", + "/nfs": "NFS", + "/storage": "Almacenamiento", + "/log": "Historial", + "/settings": "Ajustes", +}; + +interface TopbarProps { + username: string; + onLogout: () => void; +} + +export function Topbar({ username, onLogout }: TopbarProps) { + const [sidebarOpen, setSidebarOpen] = useState(false); + const location = useLocation(); + const title = pageTitles[location.pathname] ?? "nasctl"; + + return ( + <> +
+ +
+ {title} +
+
+ + {sidebarOpen && ( +
+
setSidebarOpen(false)} + /> +
+ setSidebarOpen(false)} + variant="drawer" + /> +
+
+ )} + + ); +} diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx new file mode 100644 index 0000000..f93d317 --- /dev/null +++ b/web/src/components/UserMenu.tsx @@ -0,0 +1,123 @@ +import { LogOut, Key } from "../lib/icons"; +import { DropdownMenu } from "./ui/DropdownMenu"; +import { useState } from "react"; +import { Dialog, DialogHeader, DialogTitle, DialogContent, DialogFooter } from "./ui/Dialog"; +import { Button } from "./ui/Button"; +import { Input } from "./ui/Input"; +import { Label } from "./ui/Label"; +import { api } from "../api"; +import { useToast } from "./ui/Toast"; + +interface UserMenuProps { + username: string; + onLogout: () => void; +} + +export function UserMenu({ username, onLogout }: UserMenuProps) { + const [showPasswordDialog, setShowPasswordDialog] = useState(false); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { toast } = useToast(); + + const initials = username.slice(0, 2).toUpperCase(); + + async function handleChangePassword() { + setLoading(true); + setError(null); + try { + await api.changePassword(oldPassword, newPassword); + toast({ type: "success", title: "Contraseña actualizada" }); + setShowPasswordDialog(false); + setOldPassword(""); + setNewPassword(""); + } catch (e) { + setError(e instanceof Error ? e.message : "Error al cambiar contraseña"); + } finally { + setLoading(false); + } + } + + return ( + <> + +
+ {initials} +
+
+
{username}
+
Sesión activa
+
+ + + + + } + items={[ + { + label: "Cambiar contraseña", + icon: , + onClick: () => setShowPasswordDialog(true), + }, + { separator: true }, + { + label: "Cerrar sesión", + icon: , + onClick: onLogout, + destructive: true, + }, + ]} + align="left" + /> + + setShowPasswordDialog(false)} maxWidth="sm"> + + Cambiar contraseña + + +
+ {error && ( +
{error}
+ )} +
+ + setOldPassword(e.target.value)} + className="mt-1.5" + /> +
+
+ + setNewPassword(e.target.value)} + className="mt-1.5" + /> +
+
+
+ + + + +
+ + ); +} diff --git a/web/src/components/ui/Badge.tsx b/web/src/components/ui/Badge.tsx new file mode 100644 index 0000000..f626a82 --- /dev/null +++ b/web/src/components/ui/Badge.tsx @@ -0,0 +1,37 @@ +import { HTMLAttributes } from "react"; +import { cn } from "../../lib/cn"; + +export type BadgeVariant = "default" | "secondary" | "success" | "warning" | "destructive" | "outline"; + +interface BadgeProps extends HTMLAttributes { + variant?: BadgeVariant; + size?: "sm" | "md"; +} + +const variantClasses: Record = { + default: "bg-primary/15 text-primary border-primary/20", + secondary: "bg-secondary text-secondary-foreground border-secondary", + success: "bg-success/15 text-success border-success/20", + warning: "bg-warning/15 text-warning border-warning/20", + destructive: "bg-destructive/15 text-destructive border-destructive/20", + outline: "bg-transparent text-foreground border-border", +}; + +const sizeClasses: Record, string> = { + sm: "text-[10px] px-1.5 py-px", + md: "text-xs px-2.5 py-0.5", +}; + +export function Badge({ className, variant = "default", size = "md", ...props }: BadgeProps) { + return ( + + ); +} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..3d8d1fe --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -0,0 +1,51 @@ +import { ButtonHTMLAttributes, forwardRef } from "react"; +import { cn } from "../../lib/cn"; +import { Loader2 } from "../../lib/icons"; + +export type ButtonVariant = "default" | "secondary" | "ghost" | "outline" | "destructive" | "link"; +export type ButtonSize = "sm" | "md" | "lg" | "icon"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + loading?: boolean; +} + +const variantClasses: Record = { + default: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 active:bg-secondary/70", + ghost: "hover:bg-accent hover:text-accent-foreground active:bg-accent/80", + outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground active:bg-accent/80", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/80", + link: "text-primary underline-offset-4 hover:underline active:text-primary/80", +}; + +const sizeClasses: Record = { + sm: "h-8 px-3 text-xs", + md: "h-10 px-4 text-sm", + lg: "h-12 px-6 text-base", + icon: "h-10 w-10", +}; + +export const Button = forwardRef( + ({ className, variant = "default", size = "md", loading, disabled, children, ...props }, ref) => { + return ( + + ); + } +); + +Button.displayName = "Button"; diff --git a/web/src/components/ui/Card.tsx b/web/src/components/ui/Card.tsx new file mode 100644 index 0000000..24382f5 --- /dev/null +++ b/web/src/components/ui/Card.tsx @@ -0,0 +1,52 @@ +import { HTMLAttributes, forwardRef } from "react"; +import { cn } from "../../lib/cn"; + +interface CardProps extends HTMLAttributes {} + +export const Card = forwardRef(({ className, ...props }, ref) => ( +
+)); + +Card.displayName = "Card"; + +export const CardHeader = forwardRef(({ className, ...props }, ref) => ( +
+)); + +CardHeader.displayName = "CardHeader"; + +export const CardTitle = forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); + +CardTitle.displayName = "CardTitle"; + +export const CardDescription = forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); + +CardDescription.displayName = "CardDescription"; + +export const CardContent = forwardRef(({ className, ...props }, ref) => ( +

+)); + +CardContent.displayName = "CardContent"; + +export const CardFooter = forwardRef(({ className, ...props }, ref) => ( +
+)); + +CardFooter.displayName = "CardFooter"; diff --git a/web/src/components/ui/Checkbox.tsx b/web/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..2f79286 --- /dev/null +++ b/web/src/components/ui/Checkbox.tsx @@ -0,0 +1,45 @@ +import { InputHTMLAttributes, forwardRef } from "react"; +import { cn } from "../../lib/cn"; + +interface CheckboxProps extends Omit, "type"> { + label?: string; +} + +export const Checkbox = forwardRef( + ({ className, label, id, ...props }, ref) => { + return ( + + ); + } +); + +Checkbox.displayName = "Checkbox"; diff --git a/web/src/components/ui/ConfirmDialog.tsx b/web/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..00b146f --- /dev/null +++ b/web/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,64 @@ +import { AlertTriangle } from "../../lib/icons"; +import { Button } from "./Button"; +import { Dialog, DialogTitle, DialogDescription, DialogFooter } from "./Dialog"; + +interface ConfirmDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + destructive?: boolean; + loading?: boolean; +} + +export function ConfirmDialog({ + open, + onClose, + onConfirm, + title, + description, + confirmLabel = "Confirmar", + cancelLabel = "Cancelar", + destructive = false, + loading = false, +}: ConfirmDialogProps) { + function handleConfirm() { + onConfirm(); + } + + function handleClose() { + onClose(); + } + + return ( + +
+
+ +
+
+ {title} + {description} +
+
+ + + + +
+ ); +} diff --git a/web/src/components/ui/Dialog.tsx b/web/src/components/ui/Dialog.tsx new file mode 100644 index 0000000..1467183 --- /dev/null +++ b/web/src/components/ui/Dialog.tsx @@ -0,0 +1,128 @@ +import { ReactNode, useCallback, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { X } from "../../lib/icons"; +import { cn } from "../../lib/cn"; + +interface DialogProps { + open: boolean; + onClose: () => void; + children: ReactNode; + maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "full"; + hideClose?: boolean; +} + +const maxWidthMap: Record, string> = { + sm: "max-w-sm", + md: "max-w-md", + lg: "max-w-lg", + xl: "max-w-xl", + "2xl": "max-w-2xl", + "3xl": "max-w-3xl", + "4xl": "max-w-4xl", + full: "max-w-full", +}; + +export function Dialog({ open, onClose, children, maxWidth = "lg", hideClose }: DialogProps) { + const overlayRef = useRef(null); + const contentRef = useRef(null); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key === "Tab" && contentRef.current) { + const focusable = contentRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + }, + [onClose] + ); + + useEffect(() => { + if (open) { + document.addEventListener("keydown", handleKeyDown); + document.body.style.overflow = "hidden"; + const firstFocusable = contentRef.current?.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + firstFocusable?.focus(); + } else { + document.body.style.overflow = ""; + } + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = ""; + }; + }, [open, handleKeyDown]); + + if (!open) return null; + + return createPortal( +
{ + if (e.target === overlayRef.current) onClose(); + }} + > +
+
+ {!hideClose && ( + + )} + {children} +
+
, + document.body + ); +} + +export function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return
; +} + +export function DialogTitle({ className, ...props }: React.HTMLAttributes) { + return

; +} + +export function DialogDescription({ className, ...props }: React.HTMLAttributes) { + return

; +} + +export function DialogContent({ className, ...props }: React.HTMLAttributes) { + return

; +} + +export function DialogFooter({ className, ...props }: React.HTMLAttributes) { + return
; +} diff --git a/web/src/components/ui/DropdownMenu.tsx b/web/src/components/ui/DropdownMenu.tsx new file mode 100644 index 0000000..5fe4566 --- /dev/null +++ b/web/src/components/ui/DropdownMenu.tsx @@ -0,0 +1,128 @@ +import { ReactNode, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "../../lib/cn"; + +interface DropdownItem { + label: string; + onClick?: () => void; + href?: string; + disabled?: boolean; + destructive?: boolean; + icon?: ReactNode; + separator?: never; +} + +interface DropdownSeparator { + separator: true; + label?: never; + onClick?: never; + href?: never; + disabled?: never; + destructive?: never; + icon?: never; +} + +type DropdownItemType = DropdownItem | DropdownSeparator; + +interface DropdownMenuProps { + trigger: ReactNode; + items: DropdownItemType[]; + align?: "left" | "right"; +} + +export function DropdownMenu({ trigger, items, align = "right" }: DropdownMenuProps) { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => { + if (!open) return; + + function handleClickOutside(e: MouseEvent) { + if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + triggerRef.current && + !triggerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + function handleTrigger() { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setPosition({ + top: rect.bottom + 4, + left: rect.left, + }); + } + setOpen((prev) => !prev); + } + + function handleItemClick(item: DropdownItem) { + if (item.disabled) return; + item.onClick?.(); + setOpen(false); + } + + return ( + <> + + {open && position + ? createPortal( +
+ {items.map((item, i) => { + if ("separator" in item && item.separator) { + return
; + } + const typedItem = item as DropdownItem; + return ( + + ); + })} +
, + document.body + ) + : null} + + ); +} diff --git a/web/src/components/ui/EmptyState.tsx b/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..f616c3b --- /dev/null +++ b/web/src/components/ui/EmptyState.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from "react"; +import { Button } from "./Button"; + +interface EmptyStateProps { + icon?: ReactNode; + title: string; + description?: string; + action?: { + label: string; + onClick: () => void; + }; + className?: string; +} + +export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ {action && ( + + )} +
+ ); +} diff --git a/web/src/components/ui/Input.tsx b/web/src/components/ui/Input.tsx new file mode 100644 index 0000000..52d6a4c --- /dev/null +++ b/web/src/components/ui/Input.tsx @@ -0,0 +1,40 @@ +import { forwardRef, InputHTMLAttributes, ReactNode } from "react"; +import { cn } from "../../lib/cn"; + +interface InputProps extends InputHTMLAttributes { + error?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; +} + +export const Input = forwardRef( + ({ className, error, leftIcon, rightIcon, ...props }, ref) => { + return ( +
+ {leftIcon && ( +
+ {leftIcon} +
+ )} + + {rightIcon && ( +
+ {rightIcon} +
+ )} +
+ ); + } +); + +Input.displayName = "Input"; diff --git a/web/src/components/ui/Label.tsx b/web/src/components/ui/Label.tsx new file mode 100644 index 0000000..c703d8c --- /dev/null +++ b/web/src/components/ui/Label.tsx @@ -0,0 +1,19 @@ +import { forwardRef, LabelHTMLAttributes } from "react"; +import { cn } from "../../lib/cn"; + +export const Label = forwardRef>( + ({ className, ...props }, ref) => { + return ( +