aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui-new/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui-new/src/components')
-rw-r--r--packages/ui-new/src/components/bottom-bar.tsx157
-rw-r--r--packages/ui-new/src/components/config-editor.tsx111
-rw-r--r--packages/ui-new/src/components/login-modal.tsx292
-rw-r--r--packages/ui-new/src/components/sidebar.tsx117
-rw-r--r--packages/ui-new/src/components/ui/avatar.tsx107
-rw-r--r--packages/ui-new/src/components/ui/dropdown-menu.tsx269
-rw-r--r--packages/ui-new/src/components/ui/field.tsx238
-rw-r--r--packages/ui-new/src/components/ui/spinner.tsx10
-rw-r--r--packages/ui-new/src/components/ui/tabs.tsx4
-rw-r--r--packages/ui-new/src/components/user-avatar.tsx23
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>
+ );
+}