diff options
Diffstat (limited to 'packages/ui-new/src/components')
| -rw-r--r-- | packages/ui-new/src/components/bottom-bar.tsx | 157 | ||||
| -rw-r--r-- | packages/ui-new/src/components/config-editor.tsx | 111 | ||||
| -rw-r--r-- | packages/ui-new/src/components/login-modal.tsx | 292 | ||||
| -rw-r--r-- | packages/ui-new/src/components/sidebar.tsx | 117 | ||||
| -rw-r--r-- | packages/ui-new/src/components/ui/avatar.tsx | 107 | ||||
| -rw-r--r-- | packages/ui-new/src/components/ui/dropdown-menu.tsx | 269 | ||||
| -rw-r--r-- | packages/ui-new/src/components/ui/field.tsx | 238 | ||||
| -rw-r--r-- | packages/ui-new/src/components/ui/spinner.tsx | 10 | ||||
| -rw-r--r-- | packages/ui-new/src/components/ui/tabs.tsx | 4 | ||||
| -rw-r--r-- | packages/ui-new/src/components/user-avatar.tsx | 23 |
10 files changed, 1022 insertions, 306 deletions
diff --git a/packages/ui-new/src/components/bottom-bar.tsx b/packages/ui-new/src/components/bottom-bar.tsx index a0c2c00..2653880 100644 --- a/packages/ui-new/src/components/bottom-bar.tsx +++ b/packages/ui-new/src/components/bottom-bar.tsx @@ -1,11 +1,13 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { Check, ChevronDown, Play, Terminal, User } from "lucide-react"; +import { Check, ChevronDown, Play, User } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useAuthStore } from "@/stores/auth-store"; +import { cn } from "@/lib/utils"; +import { useAuthStore } from "@/models/auth"; import { useGameStore } from "@/stores/game-store"; import { useInstancesStore } from "@/stores/instances-store"; -import { useUIStore } from "@/stores/ui-store"; +import { LoginModal } from "./login-modal"; +import { Button } from "./ui/button"; interface InstalledVersion { id: string; @@ -16,15 +18,13 @@ export function BottomBar() { const authStore = useAuthStore(); const gameStore = useGameStore(); const instancesStore = useInstancesStore(); - const uiStore = useUIStore(); const [isVersionDropdownOpen, setIsVersionDropdownOpen] = useState(false); const [installedVersions, setInstalledVersions] = useState< InstalledVersion[] >([]); const [isLoadingVersions, setIsLoadingVersions] = useState(true); - - const dropdownRef = useRef<HTMLDivElement>(null); + const [showLoginModal, setShowLoginModal] = useState(false); const loadInstalledVersions = useCallback(async () => { if (!instancesStore.activeInstanceId) { @@ -61,17 +61,6 @@ export function BottomBar() { useEffect(() => { loadInstalledVersions(); - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setIsVersionDropdownOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - // Listen for backend events that should refresh installed versions. let unlistenDownload: UnlistenFn | null = null; let unlistenVersionDeleted: UnlistenFn | null = null; @@ -98,7 +87,6 @@ export function BottomBar() { })(); return () => { - document.removeEventListener("mousedown", handleClickOutside); try { if (unlistenDownload) unlistenDownload(); } catch { @@ -120,12 +108,12 @@ export function BottomBar() { }; const handleStartGame = async () => { - await gameStore.startGame( - authStore.currentAccount, - authStore.openLoginModal, - instancesStore.activeInstanceId, - uiStore.setView, - ); + // await gameStore.startGame( + // authStore.currentAccount, + // authStore.openLoginModal, + // instancesStore.activeInstanceId, + // uiStore.setView, + // ); }; const getVersionTypeColor = (type: string) => { @@ -155,8 +143,7 @@ export function BottomBar() { return ( <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/30 via-transparent to-transparent p-4 z-10"> <div className="max-w-7xl mx-auto"> - <div className="flex items-center justify-between bg-white/5 dark:bg-black/20 backdrop-blur-xl rounded-xl border border-white/10 dark:border-white/5 p-3 shadow-lg"> - {/* Left: Instance Info */} + <div className="flex items-center justify-between bg-white/5 dark:bg-black/20 backdrop-blur-xl border border-white/10 dark:border-white/5 p-3 shadow-lg"> <div className="flex items-center gap-4"> <div className="flex flex-col"> <span className="text-xs font-mono text-zinc-400 uppercase tracking-wider"> @@ -166,104 +153,38 @@ export function BottomBar() { {instancesStore.activeInstance?.name || "No instance selected"} </span> </div> - - {/* Version Selector */} - <div className="relative" ref={dropdownRef}> - <button - type="button" - onClick={() => setIsVersionDropdownOpen(!isVersionDropdownOpen)} - className="flex items-center gap-2 px-4 py-2 bg-black/20 dark:bg-white/5 hover:bg-black/30 dark:hover:bg-white/10 rounded-lg border border-white/10 transition-colors" - > - <span className="text-sm text-white"> - {gameStore.selectedVersion || "Select Version"} - </span> - <ChevronDown - size={16} - className={`text-zinc-400 transition-transform ${ - isVersionDropdownOpen ? "rotate-180" : "" - }`} - /> - </button> - - {/* Dropdown */} - {isVersionDropdownOpen && ( - <div className="absolute bottom-full mb-2 w-64 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-bottom-2"> - <div className="p-2"> - {versionOptions.map((option) => ( - <button - type="button" - key={option.id} - onClick={() => selectVersion(option.id)} - disabled={ - option.id === "loading" || option.id === "empty" - } - className={`flex items-center justify-between w-full px-3 py-2 text-left rounded-md transition-colors ${ - gameStore.selectedVersion === option.id - ? "bg-indigo-500/20 text-indigo-300" - : "hover:bg-white/5 text-zinc-300" - } ${ - option.id === "loading" || option.id === "empty" - ? "opacity-50 cursor-not-allowed" - : "" - }`} - > - <div className="flex items-center gap-2"> - <div - className={`w-2 h-2 rounded-full ${getVersionTypeColor( - option.type, - )}`} - ></div> - <span className="text-sm font-medium"> - {option.label} - </span> - </div> - {gameStore.selectedVersion === option.id && ( - <Check size={14} className="text-indigo-400" /> - )} - </button> - ))} - </div> - </div> - )} - </div> </div> - {/* Right: Action Buttons */} <div className="flex items-center gap-3"> - {/* Console Toggle */} - <button - type="button" - onClick={() => uiStore.toggleConsole()} - className="flex items-center gap-2 px-3 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-white rounded-lg transition-colors" - > - <Terminal size={16} /> - <span className="text-sm font-medium">Console</span> - </button> - - {/* User Login/Info */} - <button - type="button" - onClick={() => authStore.openLoginModal()} - className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg transition-colors" - > - <User size={16} /> - <span className="text-sm font-medium"> - {authStore.currentAccount?.username || "Login"} - </span> - </button> - - {/* Start Game */} - <button - type="button" - onClick={handleStartGame} - className="flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg transition-colors shadow-lg shadow-emerald-500/20" - > - <Play size={16} /> - <span className="text-sm font-medium">Start</span> - </button> + {authStore.account ? ( + <Button + className={cn( + "px-4 py-2 shadow-xl", + "bg-emerald-600! hover:bg-emerald-500!", + )} + size="lg" + onClick={handleStartGame} + > + <Play /> + Start + </Button> + ) : ( + <Button + className="px-4 py-2" + size="lg" + onClick={() => setShowLoginModal(true)} + > + <User /> Login + </Button> + )} </div> </div> </div> + + <LoginModal + open={showLoginModal} + onOpenChange={() => setShowLoginModal(false)} + /> </div> ); } diff --git a/packages/ui-new/src/components/config-editor.tsx b/packages/ui-new/src/components/config-editor.tsx new file mode 100644 index 0000000..129b8f7 --- /dev/null +++ b/packages/ui-new/src/components/config-editor.tsx @@ -0,0 +1,111 @@ +import type React from "react"; +import { useEffect, useState } from "react"; +import { type ZodType, z } from "zod"; +import { useSettingsStore } from "@/models/settings"; +import type { LauncherConfig } from "@/types"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { FieldError } from "./ui/field"; +import { Spinner } from "./ui/spinner"; +import { Textarea } from "./ui/textarea"; + +const launcherConfigSchema: ZodType<LauncherConfig> = z.object({ + minMemory: z.number(), + maxMemory: z.number(), + javaPath: z.string(), + width: z.number(), + height: z.number(), + downloadThreads: z.number(), + customBackgroundPath: z.string().nullable(), + enableGpuAcceleration: z.boolean(), + enableVisualEffects: z.boolean(), + activeEffect: z.string(), + theme: z.string(), + logUploadService: z.string(), + pastebinApiKey: z.string().nullable(), + assistant: z.any(), // TODO: AssistantConfig schema + useSharedCaches: z.boolean(), + keepLegacyPerInstanceStorage: z.boolean(), + featureFlags: z.any(), // TODO: FeatureFlags schema +}); + +export interface ConfigEditorProps + extends Omit<React.ComponentPropsWithoutRef<typeof Dialog>, "onOpenChange"> { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ConfigEditor({ onOpenChange, ...props }: ConfigEditorProps) { + const settings = useSettingsStore(); + + const [errorMessage, setErrorMessage] = useState<string | null>(null); + const [rawConfigContent, setRawConfigContent] = useState( + JSON.stringify(settings.config, null, 2), + ); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + setRawConfigContent(JSON.stringify(settings.config, null, 2)); + }, [settings.config]); + + const handleSave = async () => { + setIsSaving(true); + setErrorMessage(null); + try { + const validatedConfig = launcherConfigSchema.parse( + JSON.parse(rawConfigContent), + ); + settings.config = validatedConfig; + await settings.save(); + onOpenChange?.(false); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : String(error)); + } finally { + setIsSaving(false); + } + }; + + return ( + <Dialog onOpenChange={onOpenChange} {...props}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden"> + <DialogHeader> + <DialogTitle>Edit Configuration</DialogTitle> + <DialogDescription> + Edit the raw JSON configuration file. + </DialogDescription> + </DialogHeader> + + <Textarea + value={rawConfigContent} + onChange={(e) => setRawConfigContent(e.target.value)} + className="font-mono text-sm h-100 resize-none" + spellCheck={false} + aria-invalid={!!errorMessage} + /> + + {errorMessage && <FieldError errors={[{ message: errorMessage }]} />} + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange?.(false)} + disabled={isSaving} + > + Cancel + </Button> + <Button onClick={handleSave} disabled={isSaving}> + {isSaving && <Spinner />} + Save Changes + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/packages/ui-new/src/components/login-modal.tsx b/packages/ui-new/src/components/login-modal.tsx index 9152494..49596da 100644 --- a/packages/ui-new/src/components/login-modal.tsx +++ b/packages/ui-new/src/components/login-modal.tsx @@ -1,156 +1,188 @@ import { Mail, User } from "lucide-react"; -import { useAuthStore } from "@/stores/auth-store"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { useAuthStore } from "@/models/auth"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "./ui/field"; +import { Input } from "./ui/input"; -export function LoginModal() { +export interface LoginModalProps + extends Omit<React.ComponentPropsWithoutRef<typeof Dialog>, "onOpenChange"> { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function LoginModal({ onOpenChange, ...props }: LoginModalProps) { const authStore = useAuthStore(); - const handleOfflineLogin = () => { - if (authStore.offlineUsername.trim()) { - authStore.performOfflineLogin(); - } - }; + const [offlineUsername, setOfflineUsername] = useState<string>(""); + const [errorMessage, setErrorMessage] = useState<string>(""); + const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false); - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - handleOfflineLogin(); + const handleMicrosoftLogin = useCallback(async () => { + setIsLoggingIn(true); + authStore.setLoginMode("microsoft"); + try { + await authStore.loginOnline(() => onOpenChange?.(false)); + } catch (error) { + const err = error as Error; + console.error("Failed to login with Microsoft:", err); + setErrorMessage(err.message); + } finally { + setIsLoggingIn(false); } - }; + }, [authStore.loginOnline, authStore.setLoginMode, onOpenChange]); - if (!authStore.isLoginModalOpen) return null; + const handleOfflineLogin = useCallback(async () => { + setIsLoggingIn(true); + try { + await authStore.loginOffline(offlineUsername); + toast.success("Logged in offline successfully"); + onOpenChange?.(false); + } catch (error) { + const err = error as Error; + console.error("Failed to login offline:", err); + setErrorMessage(err.message); + } finally { + setIsLoggingIn(false); + } + }, [authStore, offlineUsername, onOpenChange]); return ( - <div className="fixed inset-0 z-200 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4"> - <div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-md w-full animate-in fade-in zoom-in-95 duration-200"> - <div className="p-6"> - {/* Header */} - <div className="flex items-center justify-between mb-6"> - <h3 className="text-xl font-bold text-white">Login</h3> - <button - type="button" - onClick={() => { - authStore.setLoginMode("select"); - authStore.setOfflineUsername(""); - authStore.cancelMicrosoftLogin(); - }} - className="text-zinc-400 hover:text-white transition-colors p-1" - > - × - </button> - </div> - - {/* Content based on mode */} - {authStore.loginMode === "select" && ( - <div className="space-y-4"> - <p className="text-zinc-400 text-sm"> - Choose your preferred login method - </p> - <button - type="button" - onClick={() => authStore.startMicrosoftLogin()} - className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors" + <Dialog onOpenChange={onOpenChange} {...props}> + <DialogContent className="md:max-w-md"> + <DialogHeader> + <DialogTitle>Login</DialogTitle> + <DialogDescription> + Login to your Minecraft account or play offline + </DialogDescription> + </DialogHeader> + <div className="p-4 w-full overflow-hidden"> + {!authStore.loginMode && ( + <div className="flex flex-col space-y-4"> + <Button size="lg" onClick={handleMicrosoftLogin}> + <Mail /> + Login with Microsoft + </Button> + <Button + variant="secondary" + onClick={() => authStore.setLoginMode("offline")} + size="lg" > - <Mail size={18} /> - <span className="font-medium">Microsoft Account</span> - </button> + <User /> + Login Offline + </Button> + </div> + )} + {authStore.loginMode === "microsoft" && ( + <div className="flex flex-col space-y-4"> <button type="button" + className="text-4xl font-bold text-center bg-accent p-4 cursor-pointer" onClick={() => { - authStore.loginMode = "offline"; + if (authStore.deviceCode?.userCode) { + navigator.clipboard?.writeText( + authStore.deviceCode?.userCode, + ); + toast.success("Copied to clipboard"); + } }} - className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg transition-colors" > - <User size={18} /> - <span className="font-medium">Offline Mode</span> + {authStore.deviceCode?.userCode} </button> - </div> - )} - - {authStore.loginMode === "offline" && ( - <div className="space-y-4"> - <div> - <label - htmlFor="username" - className="block text-sm font-medium text-zinc-300 mb-2" - > - Username - </label> - <input - name="username" - type="text" - value={authStore.offlineUsername} - onChange={(e) => authStore.setOfflineUsername(e.target.value)} - onKeyDown={handleKeyPress} - className="w-full px-4 py-2.5 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors" - placeholder="Enter your Minecraft username" - /> - </div> - <div className="flex gap-3"> - <button - type="button" + <span className="text-muted-foreground w-full overflow-hidden text-ellipsis"> + To sign in, use a web browser to open the page{" "} + <a href={authStore.deviceCode?.verificationUri}> + {authStore.deviceCode?.verificationUri} + </a>{" "} + and enter the code{" "} + <code + className="font-semibold cursor-pointer" onClick={() => { - authStore.loginMode = "select"; - authStore.setOfflineUsername(""); + if (authStore.deviceCode?.userCode) { + navigator.clipboard?.writeText( + authStore.deviceCode?.userCode, + ); + } + }} + onKeyDown={() => { + if (authStore.deviceCode?.userCode) { + navigator.clipboard?.writeText( + authStore.deviceCode?.userCode, + ); + } }} - className="flex-1 px-4 py-2.5 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors" - > - Back - </button> - <button - type="button" - onClick={handleOfflineLogin} - disabled={!authStore.offlineUsername.trim()} - className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 disabled:cursor-not-allowed rounded-lg transition-colors" > - Login - </button> - </div> + {authStore.deviceCode?.userCode} + </code>{" "} + to authenticate, this code will be expired in{" "} + {authStore.deviceCode?.expiresIn} seconds. + </span> + <FieldError>{errorMessage}</FieldError> </div> )} - - {authStore.loginMode === "microsoft" && ( - <div className="space-y-4"> - {authStore.deviceCodeData && ( - <div className="bg-zinc-800/50 border border-zinc-700 rounded-lg p-4"> - <div className="text-center mb-4"> - <div className="text-xs font-mono bg-zinc-900 px-3 py-2 rounded border border-zinc-700 mb-3"> - {authStore.deviceCodeData.userCode} - </div> - <p className="text-zinc-300 text-sm font-medium"> - Your verification code - </p> - </div> - <p className="text-zinc-400 text-sm text-center"> - Visit{" "} - <a - href={authStore.deviceCodeData.verificationUri} - target="_blank" - className="text-indigo-400 hover:text-indigo-300 font-medium" - > - {authStore.deviceCodeData.verificationUri} - </a>{" "} - and enter the code above - </p> - </div> - )} - <div className="text-center"> - <p className="text-zinc-300 text-sm mb-2"> - {authStore.msLoginStatus} - </p> - <button - type="button" - onClick={() => { - authStore.cancelMicrosoftLogin(); - authStore.setLoginMode("select"); + {authStore.loginMode === "offline" && ( + <FieldGroup> + <Field> + <FieldLabel>Username</FieldLabel> + <FieldDescription> + Enter a username to play offline + </FieldDescription> + <Input + value={offlineUsername} + onChange={(e) => { + setOfflineUsername(e.target.value); + setErrorMessage(""); }} - className="text-sm text-zinc-400 hover:text-white transition-colors" - > - Cancel - </button> - </div> - </div> + aria-invalid={!!errorMessage} + /> + <FieldError>{errorMessage}</FieldError> + </Field> + </FieldGroup> )} </div> - </div> - </div> + <DialogFooter> + <div className="flex flex-col justify-center items-center"> + <span className="text-xs text-muted-foreground "> + {authStore.statusMessage} + </span> + </div> + <Button + variant="outline" + onClick={() => { + if (authStore.loginMode) { + if (authStore.loginMode === "microsoft") { + authStore.cancelLoginOnline(); + } + authStore.setLoginMode(null); + } else { + onOpenChange?.(false); + } + }} + > + Cancel + </Button> + {authStore.loginMode === "offline" && ( + <Button onClick={handleOfflineLogin} disabled={isLoggingIn}> + Login + </Button> + )} + </DialogFooter> + </DialogContent> + </Dialog> ); } diff --git a/packages/ui-new/src/components/sidebar.tsx b/packages/ui-new/src/components/sidebar.tsx index a8c899b..0147b0a 100644 --- a/packages/ui-new/src/components/sidebar.tsx +++ b/packages/ui-new/src/components/sidebar.tsx @@ -1,51 +1,62 @@ -import { Bot, Folder, Home, Package, Settings } from "lucide-react"; -import { Link, useLocation } from "react-router"; -import { useUIStore, type ViewType } from "../stores/ui-store"; +import { Folder, Home, LogOutIcon, Settings } from "lucide-react"; +import { useLocation, useNavigate } from "react-router"; +import { cn } from "@/lib/utils"; +import { useAuthStore } from "@/models/auth"; +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { UserAvatar } from "./user-avatar"; interface NavItemProps { - view: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; label: string; to: string; } -function NavItem({ view, Icon, label, to }: NavItemProps) { - const uiStore = useUIStore(); +function NavItem({ Icon, label, to }: NavItemProps) { + const navigate = useNavigate(); const location = useLocation(); - const isActive = location.pathname === to || uiStore.currentView === view; + const isActive = location.pathname === to; const handleClick = () => { - uiStore.setView(view as ViewType); + navigate(to); }; return ( - <Link to={to}> - <button - type="button" - className={`group flex items-center lg:gap-3 justify-center lg:justify-start w-full px-0 lg:px-4 py-2.5 rounded-sm transition-all duration-200 relative ${ - isActive - ? "bg-black/5 dark:bg-white/10 dark:text-white text-black font-medium" - : "dark:text-zinc-400 text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5" - }`} - onClick={handleClick} - > - <Icon className="size-5" strokeWidth={isActive ? 2.5 : 2} /> - <span className="hidden lg:block text-sm relative z-10">{label}</span> - - {/* Active Indicator */} - {isActive && ( - <div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div> - )} - </button> - </Link> + <Button + variant="ghost" + className={cn( + "w-fit lg:w-full justify-center lg:justify-start", + isActive && "relative bg-accent", + )} + size="lg" + onClick={handleClick} + > + <Icon className="size-5" strokeWidth={isActive ? 2.5 : 2} /> + <span className="hidden lg:block text-sm relative z-10">{label}</span> + {isActive && ( + <div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div> + )} + </Button> ); } export function Sidebar() { - const uiStore = useUIStore(); + const authStore = useAuthStore(); return ( - <aside className="w-20 lg:w-64 dark:bg-[#09090b] bg-white border-r dark:border-white/10 border-gray-200 flex flex-col items-center lg:items-start transition-all duration-300 shrink-0 py-6 z-20"> + <aside + className={cn( + "flex flex-col items-center lg:items-start", + "bg-sidebar transition-all duration-300", + "w-20 lg:w-64 shrink-0 py-6 h-full", + )} + > {/* Logo Area */} <div className="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6"> {/* Icon Logo (Small) */} @@ -145,35 +156,29 @@ export function Sidebar() { </div> </div> - {/* Navigation */} - <nav className="flex-1 w-full flex flex-col gap-1 px-3"> - <NavItem view="home" Icon={Home} label="Overview" to="/" /> - <NavItem - view="instances" - Icon={Folder} - label="Instances" - to="/instances" - /> - <NavItem - view="versions" - Icon={Package} - label="Versions" - to="/versions" - /> - <NavItem view="guide" Icon={Bot} label="Assistant" to="/guide" /> - <NavItem - view="settings" - Icon={Settings} - label="Settings" - to="/settings" - /> + <nav className="w-full flex flex-col space-y-1 px-3 items-center"> + <NavItem Icon={Home} label="Overview" to="/" /> + <NavItem Icon={Folder} label="Instances" to="/instances" /> + <NavItem Icon={Settings} label="Settings" to="/settings" /> </nav> - {/* Footer Info */} - <div className="p-4 w-full flex justify-center lg:justify-start lg:px-6 opacity-40 hover:opacity-100 transition-opacity"> - <div className="text-[10px] font-mono text-zinc-500 uppercase tracking-wider"> - v{uiStore.appVersion} - </div> + <div className="flex-1 flex flex-col justify-end"> + <DropdownMenu> + <DropdownMenuTrigger render={<UserAvatar />}> + Open + </DropdownMenuTrigger> + <DropdownMenuContent align="end" side="right" sideOffset={20}> + <DropdownMenuGroup> + <DropdownMenuItem + variant="destructive" + onClick={authStore.logout} + > + <LogOutIcon /> + Logout + </DropdownMenuItem> + </DropdownMenuGroup> + </DropdownMenuContent> + </DropdownMenu> </div> </aside> ); diff --git a/packages/ui-new/src/components/ui/avatar.tsx b/packages/ui-new/src/components/ui/avatar.tsx new file mode 100644 index 0000000..9fd72a2 --- /dev/null +++ b/packages/ui-new/src/components/ui/avatar.tsx @@ -0,0 +1,107 @@ +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg"; +}) { + return ( + <AvatarPrimitive.Root + data-slot="avatar" + data-size={size} + className={cn( + "size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten", + className, + )} + {...props} + /> + ); +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + <AvatarPrimitive.Image + data-slot="avatar-image" + className={cn( + "rounded-full aspect-square size-full object-cover", + className, + )} + {...props} + /> + ); +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + <AvatarPrimitive.Fallback + data-slot="avatar-fallback" + className={cn( + "bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs", + className, + )} + {...props} + /> + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + <span + data-slot="avatar-badge" + className={cn( + "bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none", + "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className, + )} + {...props} + /> + ); +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="avatar-group" + className={cn( + "*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2", + className, + )} + {...props} + /> + ); +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="avatar-group-count" + className={cn( + "bg-muted text-muted-foreground size-8 rounded-full text-xs group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", + className, + )} + {...props} + /> + ); +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +}; diff --git a/packages/ui-new/src/components/ui/dropdown-menu.tsx b/packages/ui-new/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ee97374 --- /dev/null +++ b/packages/ui-new/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,269 @@ +import { Menu as MenuPrimitive } from "@base-ui/react/menu"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />; +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />; +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />; +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + <MenuPrimitive.Portal> + <MenuPrimitive.Positioner + className="isolate z-50 outline-none" + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + > + <MenuPrimitive.Popup + data-slot="dropdown-menu-content" + className={cn( + "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-none shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden", + className, + )} + {...props} + /> + </MenuPrimitive.Positioner> + </MenuPrimitive.Portal> + ); +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />; +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean; +}) { + return ( + <MenuPrimitive.GroupLabel + data-slot="dropdown-menu-label" + data-inset={inset} + className={cn( + "text-muted-foreground px-2 py-2 text-xs data-inset:pl-7", + className, + )} + {...props} + /> + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + <MenuPrimitive.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 not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + {...props} + /> + ); +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean; +}) { + return ( + <MenuPrimitive.SubmenuTrigger + data-slot="dropdown-menu-sub-trigger" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + {...props} + > + {children} + <ChevronRightIcon className="ml-auto" /> + </MenuPrimitive.SubmenuTrigger> + ); +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps<typeof DropdownMenuContent>) { + return ( + <DropdownMenuContent + data-slot="dropdown-menu-sub-content" + className={cn( + "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 bg-popover text-popover-foreground min-w-24 rounded-none shadow-lg ring-1 duration-100 w-auto", + className, + )} + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + {...props} + /> + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean; +}) { + return ( + <MenuPrimitive.CheckboxItem + data-slot="dropdown-menu-checkbox-item" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + checked={checked} + {...props} + > + <span + className="absolute right-2 flex items-center justify-center pointer-events-none" + data-slot="dropdown-menu-checkbox-item-indicator" + > + <MenuPrimitive.CheckboxItemIndicator> + <CheckIcon /> + </MenuPrimitive.CheckboxItemIndicator> + </span> + {children} + </MenuPrimitive.CheckboxItem> + ); +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + <MenuPrimitive.RadioGroup + data-slot="dropdown-menu-radio-group" + {...props} + /> + ); +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { + return ( + <MenuPrimitive.RadioItem + data-slot="dropdown-menu-radio-item" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + {...props} + > + <span + className="absolute right-2 flex items-center justify-center pointer-events-none" + data-slot="dropdown-menu-radio-item-indicator" + > + <MenuPrimitive.RadioItemIndicator> + <CheckIcon /> + </MenuPrimitive.RadioItemIndicator> + </span> + {children} + </MenuPrimitive.RadioItem> + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + <MenuPrimitive.Separator + data-slot="dropdown-menu-separator" + className={cn("bg-border -mx-1 h-px", className)} + {...props} + /> + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + <span + data-slot="dropdown-menu-shortcut" + className={cn( + "text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", + className, + )} + {...props} + /> + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/packages/ui-new/src/components/ui/field.tsx b/packages/ui-new/src/components/ui/field.tsx new file mode 100644 index 0000000..ab9fb71 --- /dev/null +++ b/packages/ui-new/src/components/ui/field.tsx @@ -0,0 +1,238 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { useMemo } from "react"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( + <fieldset + data-slot="field-set" + className={cn( + "gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + <legend + data-slot="field-legend" + data-variant={variant} + className={cn( + "mb-2.5 font-medium data-[variant=label]:text-xs data-[variant=legend]:text-sm", + className, + )} + {...props} + /> + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-group" + className={cn( + "gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col", + className, + )} + {...props} + /> + ); +} + +const fieldVariants = cva( + "data-[invalid=true]:text-destructive gap-2 group/field flex w-full", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { + return ( + <div + data-slot="field" + data-orientation={orientation} + className={cn(fieldVariants({ orientation }), className)} + {...props} + /> + ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-content" + className={cn( + "gap-0.5 group/field-content flex flex-1 flex-col leading-snug", + className, + )} + {...props} + /> + ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps<typeof Label>) { + return ( + <Label + data-slot="field-label" + className={cn( + "has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 group/field-label peer/field-label flex w-fit leading-snug", + "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col", + className, + )} + {...props} + /> + ); +} + +function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-label" + className={cn( + "gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug", + className, + )} + {...props} + /> + ); +} + +function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( + <p + data-slot="field-description" + className={cn( + "text-muted-foreground text-left text-xs/relaxed [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance", + "last:mt-0 nth-last-2:-mt-1", + "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className, + )} + {...props} + /> + ); +} + +function FieldSeparator({ + children, + className, + ...props +}: React.ComponentProps<"div"> & { + children?: React.ReactNode; +}) { + return ( + <div + data-slot="field-separator" + data-content={!!children} + className={cn( + "-my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2 relative", + className, + )} + {...props} + > + <Separator className="absolute inset-0 top-1/2" /> + {children && ( + <span + className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit" + data-slot="field-separator-content" + > + {children} + </span> + )} + </div> + ); +} + +function FieldError({ + className, + children, + errors, + ...props +}: React.ComponentProps<"div"> & { + errors?: Array<{ message?: string } | undefined>; +}) { + const content = useMemo(() => { + if (children) { + return children; + } + + if (!errors?.length) { + return null; + } + + const uniqueErrors = [ + ...new Map(errors.map((error) => [error?.message, error])).values(), + ]; + + if (uniqueErrors?.length === 1) { + return uniqueErrors[0]?.message; + } + + return ( + <ul className="ml-4 flex list-disc flex-col gap-1"> + {uniqueErrors.map( + (error, index) => + error?.message && ( + <li key={`${error.message.slice(6)}-${index}`}> + {error.message} + </li> + ), + )} + </ul> + ); + }, [children, errors]); + + if (!content) { + return null; + } + + return ( + <div + role="alert" + data-slot="field-error" + className={cn("text-destructive text-xs font-normal", className)} + {...props} + > + {content} + </div> + ); +} + +export { + Field, + FieldLabel, + FieldDescription, + FieldError, + FieldGroup, + FieldLegend, + FieldSeparator, + FieldSet, + FieldContent, + FieldTitle, +}; diff --git a/packages/ui-new/src/components/ui/spinner.tsx b/packages/ui-new/src/components/ui/spinner.tsx new file mode 100644 index 0000000..91f6a63 --- /dev/null +++ b/packages/ui-new/src/components/ui/spinner.tsx @@ -0,0 +1,10 @@ +import { cn } from "@/lib/utils" +import { Loader2Icon } from "lucide-react" + +function Spinner({ className, ...props }: React.ComponentProps<"svg">) { + return ( + <Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} /> + ) +} + +export { Spinner } diff --git a/packages/ui-new/src/components/ui/tabs.tsx b/packages/ui-new/src/components/ui/tabs.tsx index 6349f40..c66893f 100644 --- a/packages/ui-new/src/components/ui/tabs.tsx +++ b/packages/ui-new/src/components/ui/tabs.tsx @@ -22,7 +22,7 @@ function Tabs({ } const tabsListVariants = cva( - "rounded-none p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col", + "rounded-none p-0.75 group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col", { variants: { variant: { @@ -59,7 +59,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { "gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground", - "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", + "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:-bottom-1.25 group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", className, )} {...props} diff --git a/packages/ui-new/src/components/user-avatar.tsx b/packages/ui-new/src/components/user-avatar.tsx new file mode 100644 index 0000000..bbdb84c --- /dev/null +++ b/packages/ui-new/src/components/user-avatar.tsx @@ -0,0 +1,23 @@ +import { useAuthStore } from "@/models/auth"; +import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "./ui/avatar"; + +export function UserAvatar({ + className, + ...props +}: React.ComponentProps<typeof Avatar>) { + const authStore = useAuthStore(); + + if (!authStore.account) { + return null; + } + + return ( + <Avatar {...props}> + <AvatarImage + src={`https://minotar.net/helm/${authStore.account.username}/100.png`} + /> + <AvatarFallback>{authStore.account.username.slice(0, 2)}</AvatarFallback> + <AvatarBadge /> + </Avatar> + ); +} |