diff options
Diffstat (limited to 'packages')
20 files changed, 1790 insertions, 678 deletions
diff --git a/packages/ui-new/package.json b/packages/ui-new/package.json index fcd6aed..b26d733 100644 --- a/packages/ui-new/package.json +++ b/packages/ui-new/package.json @@ -26,6 +26,7 @@ "@tauri-apps/plugin-shell": "^2.3.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "es-toolkit": "^1.44.0", "lucide-react": "^0.562.0", "marked": "^17.0.1", "next-themes": "^0.4.6", @@ -35,6 +36,7 @@ "react-router": "^7.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.1", + "zod": "^4.3.6", "zustand": "^5.0.10" }, "devDependencies": { 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> + ); +} diff --git a/packages/ui-new/src/main.tsx b/packages/ui-new/src/main.tsx index e2ae9c2..bda693d 100644 --- a/packages/ui-new/src/main.tsx +++ b/packages/ui-new/src/main.tsx @@ -7,6 +7,7 @@ import { AssistantView } from "./pages/assistant-view"; import { HomeView } from "./pages/home-view"; import { IndexPage } from "./pages/index"; import { InstancesView } from "./pages/instances-view"; +import { SettingsPage } from "./pages/settings"; import { SettingsView } from "./pages/settings-view"; import { VersionsView } from "./pages/versions-view"; @@ -29,12 +30,12 @@ const router = createHashRouter([ }, { path: "settings", - element: <SettingsView />, - }, - { - path: "guide", - element: <AssistantView />, + element: <SettingsPage />, }, + // { + // path: "guide", + // element: <AssistantView />, + // }, ], }, ]); diff --git a/packages/ui-new/src/models/auth.ts b/packages/ui-new/src/models/auth.ts new file mode 100644 index 0000000..10b2a0d --- /dev/null +++ b/packages/ui-new/src/models/auth.ts @@ -0,0 +1,142 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { open } from "@tauri-apps/plugin-shell"; +import { Mutex } from "es-toolkit"; +import { toString as stringify } from "es-toolkit/compat"; +import { toast } from "sonner"; +import { create } from "zustand"; +import { + completeMicrosoftLogin, + getActiveAccount, + loginOffline, + logout, + startMicrosoftLogin, +} from "@/client"; +import type { Account, DeviceCodeResponse } from "@/types"; + +export interface AuthState { + account: Account | null; + loginMode: Account["type"] | null; + deviceCode: DeviceCodeResponse | null; + _pollingInterval: number | null; + _mutex: Mutex; + statusMessage: string | null; + _progressUnlisten: UnlistenFn | null; + + init: () => Promise<void>; + setLoginMode: (mode: Account["type"] | null) => void; + loginOnline: (onSuccess?: () => void | Promise<void>) => Promise<void>; + _pollLoginStatus: ( + deviceCode: string, + onSuccess?: () => void | Promise<void>, + ) => Promise<void>; + cancelLoginOnline: () => Promise<void>; + loginOffline: (username: string) => Promise<void>; + logout: () => Promise<void>; +} + +export const useAuthStore = create<AuthState>((set, get) => ({ + account: null, + loginMode: null, + deviceCode: null, + _pollingInterval: null, + statusMessage: null, + _progressUnlisten: null, + _mutex: new Mutex(), + + init: async () => { + try { + const account = await getActiveAccount(); + set({ account }); + } catch (error) { + console.error("Failed to initialize auth store:", error); + } + }, + setLoginMode: (mode) => set({ loginMode: mode }), + loginOnline: async (onSuccess) => { + const { _pollLoginStatus } = get(); + + set({ statusMessage: "Waiting for authorization..." }); + + try { + const unlisten = await listen("auth-progress", (event) => { + const message = event.payload; + console.log(message); + set({ statusMessage: stringify(message), _progressUnlisten: unlisten }); + }); + } catch (error) { + console.warn("Failed to attch auth-progress listener:", error); + toast.warning("Failed to attch auth-progress listener"); + } + + const deviceCode = await startMicrosoftLogin(); + navigator.clipboard?.writeText(deviceCode.userCode).catch((err) => { + console.error("Failed to copy to clipboard:", err); + }); + open(deviceCode.verificationUri).catch((err) => { + console.error("Failed to open browser:", err); + }); + const ms = Number(deviceCode.interval) * 1000; + const interval = setInterval(() => { + _pollLoginStatus(deviceCode.deviceCode, onSuccess); + }, ms); + set({ _pollingInterval: interval, deviceCode }); + }, + _pollLoginStatus: async (deviceCode, onSuccess) => { + const { _pollingInterval, _mutex: mutex, _progressUnlisten } = get(); + if (mutex.isLocked) return; + mutex.acquire(); + try { + const account = await completeMicrosoftLogin(deviceCode); + clearInterval(_pollingInterval ?? undefined); + _progressUnlisten?.(); + onSuccess?.(); + set({ account, loginMode: "microsoft" }); + } catch (error) { + if (error === "authorization_pending") { + console.log("Authorization pending..."); + } else { + console.error("Failed to poll login status:", error); + toast.error("Failed to poll login status"); + } + } finally { + mutex.release(); + } + }, + cancelLoginOnline: async () => { + const { account, logout, _pollingInterval, _progressUnlisten } = get(); + clearInterval(_pollingInterval ?? undefined); + _progressUnlisten?.(); + if (account) { + await logout(); + } + set({ + loginMode: null, + _pollingInterval: null, + statusMessage: null, + _progressUnlisten: null, + }); + }, + loginOffline: async (username: string) => { + const trimmedUsername = username.trim(); + if (trimmedUsername.length === 0) { + throw new Error("Username cannot be empty"); + } + + try { + const account = await loginOffline(trimmedUsername); + set({ account, loginMode: "offline" }); + } catch (error) { + console.error("Failed to login offline:", error); + toast.error("Failed to login offline"); + } + }, + logout: async () => { + try { + await logout(); + set({ account: null }); + } catch (error) { + console.error("Failed to logout:", error); + toast.error("Failed to logout"); + } + }, +})); diff --git a/packages/ui-new/src/models/settings.ts b/packages/ui-new/src/models/settings.ts new file mode 100644 index 0000000..9f4119c --- /dev/null +++ b/packages/ui-new/src/models/settings.ts @@ -0,0 +1,75 @@ +import { toast } from "sonner"; +import { create } from "zustand/react"; +import { getConfigPath, getSettings, saveSettings } from "@/client"; +import type { LauncherConfig } from "@/types"; + +export interface SettingsState { + config: LauncherConfig | null; + configPath: string | null; + + /* Theme getter */ + get theme(): string; + /* Apply theme to the document */ + applyTheme: (theme?: string) => void; + + /* Refresh settings from the backend */ + refresh: () => Promise<void>; + /* Save settings to the backend */ + save: () => Promise<void>; + /* Update settings in the backend */ + update: (config: LauncherConfig) => Promise<void>; + /* Merge settings with the current config without saving */ + merge: (config: Partial<LauncherConfig>) => void; +} + +export const useSettingsStore = create<SettingsState>((set, get) => ({ + config: null, + configPath: null, + + get theme() { + const { config } = get(); + return config?.theme || "dark"; + }, + applyTheme: (theme?: string) => { + const { config } = get(); + if (!config) return; + if (!theme) theme = config.theme; + let themeValue = theme; + if (theme === "system") { + themeValue = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + document.documentElement.classList.remove("light", "dark"); + document.documentElement.setAttribute("data-theme", themeValue); + document.documentElement.classList.add(themeValue); + set({ config: { ...config, theme } }); + }, + + refresh: async () => { + const { applyTheme } = get(); + try { + const settings = await getSettings(); + const path = await getConfigPath(); + set({ config: settings, configPath: path }); + applyTheme(settings.theme); + } catch (error) { + console.error("Failed to load settings:", error); + toast.error("Failed to load settings"); + } + }, + save: async () => { + const { config } = get(); + if (!config) return; + await saveSettings(config); + }, + update: async (config) => { + await saveSettings(config); + set({ config }); + }, + merge: (config) => { + const { config: currentConfig } = get(); + if (!currentConfig) throw new Error("Settings not loaded"); + set({ config: { ...currentConfig, ...config } }); + }, +})); diff --git a/packages/ui-new/src/pages/home-view.tsx b/packages/ui-new/src/pages/home-view.tsx index bcee7e6..4f80cb0 100644 --- a/packages/ui-new/src/pages/home-view.tsx +++ b/packages/ui-new/src/pages/home-view.tsx @@ -1,5 +1,5 @@ -import { Calendar, ExternalLink } from "lucide-react"; import { useEffect, useState } from "react"; +import { BottomBar } from "@/components/bottom-bar"; import type { SaturnEffect } from "@/lib/effects/SaturnEffect"; import { useGameStore } from "../stores/game-store"; import { useReleasesStore } from "../stores/releases-store"; @@ -108,125 +108,6 @@ export function HomeView() { } }; - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }); - }; - - const escapeHtml = (unsafe: string) => { - return unsafe - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - }; - - const formatBody = (body: string) => { - if (!body) return ""; - - let processed = escapeHtml(body); - - const emojiMap: Record<string, string> = { - ":tada:": "🎉", - ":sparkles:": "✨", - ":bug:": "🐛", - ":memo:": "📝", - ":rocket:": "🚀", - ":white_check_mark:": "✅", - ":construction:": "🚧", - ":recycle:": "♻️", - ":wrench:": "🔧", - ":package:": "📦", - ":arrow_up:": "⬆️", - ":arrow_down:": "⬇️", - ":warning:": "⚠️", - ":fire:": "🔥", - ":heart:": "❤️", - ":star:": "⭐", - ":zap:": "⚡", - ":art:": "🎨", - ":lipstick:": "💄", - ":globe_with_meridians:": "🌐", - }; - - processed = processed.replace( - /:[a-z0-9_]+:/g, - (match) => emojiMap[match] || match, - ); - - processed = processed.replace(/`([0-9a-f]{7,40})`/g, (_match, hash) => { - return `<a href="https://github.com/HydroRoll-Team/DropOut/commit/${hash}" target="_blank" class="text-emerald-500 hover:underline font-mono bg-emerald-500/10 px-1 rounded text-[10px] py-0.5 transition-colors border border-emerald-500/20 hover:border-emerald-500/50">${hash.substring( - 0, - 7, - )}</a>`; - }); - - processed = processed.replace( - /@([a-zA-Z0-9-]+)/g, - '<a href="https://github.com/$1" target="_blank" class="text-zinc-300 hover:text-white hover:underline font-medium">@$1</a>', - ); - - return processed - .split("\n") - .map((line) => { - line = line.trim(); - - const formatLine = (text: string) => - text - .replace( - /\*\*(.*?)\*\*/g, - '<strong class="text-zinc-200">$1</strong>', - ) - .replace( - /(?<!\*)\*([^*]+)\*(?!\*)/g, - '<em class="text-zinc-400 italic">$1</em>', - ) - .replace( - /`([^`]+)`/g, - '<code class="bg-zinc-800 px-1 py-0.5 rounded text-xs text-zinc-300 font-mono border border-white/5 break-all whitespace-normal">$1</code>', - ) - .replace( - /\[(.*?)\]\((.*?)\)/g, - '<a href="$2" target="_blank" class="text-indigo-400 hover:text-indigo-300 hover:underline decoration-indigo-400/30 break-all">$1</a>', - ); - - if (line.startsWith("- ") || line.startsWith("* ")) { - return `<li class="ml-4 list-disc marker:text-zinc-600 mb-1 pl-1 text-zinc-400">${formatLine( - line.substring(2), - )}</li>`; - } - - if (line.startsWith("##")) { - return `<h3 class="text-sm font-bold mt-6 mb-3 text-zinc-100 flex items-center gap-2 border-b border-white/5 pb-2 uppercase tracking-wide">${line.replace( - /^#+\s+/, - "", - )}</h3>`; - } - - if (line.startsWith("#")) { - return `<h3 class="text-base font-bold mt-6 mb-3 text-white">${line.replace( - /^#+\s+/, - "", - )}</h3>`; - } - - if (line.startsWith("> ")) { - return `<blockquote class="border-l-2 border-zinc-700 pl-4 py-1 my-2 text-zinc-500 italic bg-white/5 rounded-r-sm">${formatLine( - line.substring(2), - )}</blockquote>`; - } - - if (line === "") return '<div class="h-2"></div>'; - - return `<p class="mb-1.5 leading-relaxed">${formatLine(line)}</p>`; - }) - .join(""); - }; - return ( <div className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth" @@ -286,96 +167,7 @@ export function HomeView() { </div> </div> - {/* Scroll Hint */} - {!releasesStore.isLoading && releasesStore.releases.length > 0 && ( - <div className="absolute bottom-10 left-12 animate-bounce text-zinc-600 flex flex-col items-center gap-2 w-fit opacity-50 hover:opacity-100 transition-opacity"> - <span className="text-[10px] font-mono uppercase tracking-widest"> - Scroll for Updates - </span> - <svg - width="20" - height="20" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - > - <title>Scroll for Updates</title> - <path d="M7 13l5 5 5-5M7 6l5 5 5-5" /> - </svg> - </div> - )} - </div> - - {/* Changelog / Updates Section */} - <div className="bg-[#09090b] relative z-20 px-12 pb-24 pt-12 border-t border-white/5 min-h-[50vh]"> - <div className="max-w-4xl"> - <h2 className="text-2xl font-bold text-white mb-10 flex items-center gap-3"> - <span className="w-1.5 h-8 bg-emerald-500 rounded-sm"></span> - LATEST UPDATES - </h2> - - {releasesStore.isLoading ? ( - <div className="flex flex-col gap-8"> - {Array(3) - .fill(0) - .map((_, i) => ( - <div - key={`release_skeleton_${i.toString()}`} - className="h-48 bg-white/5 rounded-sm animate-pulse border border-white/5" - ></div> - ))} - </div> - ) : releasesStore.error ? ( - <div className="p-6 border border-red-500/20 bg-red-500/10 text-red-400 rounded-sm"> - Failed to load updates: {releasesStore.error} - </div> - ) : releasesStore.releases.length === 0 ? ( - <div className="text-zinc-500 italic">No releases found.</div> - ) : ( - <div className="space-y-12"> - {releasesStore.releases.map((release, index) => ( - <div - key={`${release.name}_${index.toString()}`} - className="group relative pl-8 border-l border-white/10 pb-4 last:pb-0 last:border-l-0" - > - {/* Timeline Dot */} - <div className="absolute -left-1.25 top-1.5 w-2.5 h-2.5 rounded-full bg-zinc-800 border border-zinc-600 group-hover:bg-emerald-500 group-hover:border-emerald-400 transition-colors"></div> - - <div className="flex items-baseline gap-4 mb-3"> - <h3 className="text-xl font-bold text-white group-hover:text-emerald-400 transition-colors"> - {release.name || release.tagName} - </h3> - <div className="text-xs font-mono text-zinc-500 flex items-center gap-2"> - <Calendar size={12} /> - {formatDate(release.publishedAt)} - </div> - </div> - - <div className="bg-zinc-900/50 border border-white/5 hover:border-white/10 rounded-sm p-6 text-zinc-400 text-sm leading-relaxed transition-colors overflow-hidden"> - <div - className="prose prose-invert prose-sm max-w-none prose-p:text-zinc-400 prose-headings:text-zinc-200 prose-ul:my-2 prose-li:my-0 break-words whitespace-normal" - dangerouslySetInnerHTML={{ - __html: formatBody(release.body), - }} - /> - </div> - - <a - href={release.htmlUrl} - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center gap-2 mt-3 text-[10px] font-bold uppercase tracking-wider text-zinc-600 hover:text-white transition-colors" - > - View full changelog on GitHub <ExternalLink size={10} /> - </a> - </div> - ))} - </div> - )} - </div> + <BottomBar /> </div> </div> ); diff --git a/packages/ui-new/src/pages/index-old.tsx b/packages/ui-new/src/pages/index-old.tsx new file mode 100644 index 0000000..a6626c9 --- /dev/null +++ b/packages/ui-new/src/pages/index-old.tsx @@ -0,0 +1,187 @@ +import { useEffect } from "react"; +import { Outlet } from "react-router"; +import { BottomBar } from "@/components/bottom-bar"; +import { DownloadMonitor } from "@/components/download-monitor"; +import { GameConsole } from "@/components/game-console"; +import { LoginModal } from "@/components/login-modal"; +import { ParticleBackground } from "@/components/particle-background"; +import { Sidebar } from "@/components/sidebar"; + +import { useAuthStore } from "@/stores/auth-store"; +import { useGameStore } from "@/stores/game-store"; +import { useInstancesStore } from "@/stores/instances-store"; +import { useLogsStore } from "@/stores/logs-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useUIStore } from "@/stores/ui-store"; + +export function IndexPage() { + const authStore = useAuthStore(); + const settingsStore = useSettingsStore(); + const uiStore = useUIStore(); + const instancesStore = useInstancesStore(); + const gameStore = useGameStore(); + const logsStore = useLogsStore(); + useEffect(() => { + // ENFORCE DARK MODE: Always add 'dark' class and attribute + document.documentElement.classList.add("dark"); + document.documentElement.setAttribute("data-theme", "dark"); + document.documentElement.classList.remove("light"); + + // Initialize stores + // Include store functions in the dependency array to satisfy hooks lint. + // These functions are stable in our store implementation, so listing them + // here is safe and prevents lint warnings. + authStore.checkAccount(); + settingsStore.loadSettings(); + logsStore.init(); + settingsStore.detectJava(); + instancesStore.loadInstances(); + gameStore.loadVersions(); + + // Note: getVersion() would need Tauri API setup + // getVersion().then((v) => uiStore.setAppVersion(v)); + }, [ + authStore.checkAccount, + settingsStore.loadSettings, + logsStore.init, + settingsStore.detectJava, + instancesStore.loadInstances, + gameStore.loadVersions, + ]); + + // Refresh versions when active instance changes + useEffect(() => { + if (instancesStore.activeInstanceId) { + gameStore.loadVersions(); + } else { + gameStore.setVersions([]); + } + }, [ + instancesStore.activeInstanceId, + gameStore.loadVersions, + gameStore.setVersions, + ]); + + return ( + <div className="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30"> + {/* Modern Animated Background */} + <div className="absolute inset-0 z-0 bg-gray-100 dark:bg-[#09090b] overflow-hidden"> + {settingsStore.settings.customBackgroundPath && ( + <img + src={settingsStore.settings.customBackgroundPath} + alt="Background" + className="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear" + onError={(e) => console.error("Failed to load main background:", e)} + /> + )} + + {/* Dimming Overlay for readability */} + {settingsStore.settings.customBackgroundPath && ( + <div className="absolute inset-0 bg-black/50"></div> + )} + + {!settingsStore.settings.customBackgroundPath && ( + <> + {settingsStore.settings.theme === "dark" ? ( + <div className="absolute inset-0 opacity-60 bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950"></div> + ) : ( + <div className="absolute inset-0 opacity-100 bg-linear-to-br from-emerald-100 via-gray-100 to-indigo-100"></div> + )} + + {uiStore.currentView === "home" && <ParticleBackground />} + + <div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50"></div> + </> + )} + + {/* Subtle Grid Overlay */} + <div + className="absolute inset-0 z-0 dark:opacity-10 opacity-30 pointer-events-none" + style={{ + backgroundImage: `linear-gradient(${ + settingsStore.settings.theme === "dark" ? "#ffffff" : "#000000" + } 1px, transparent 1px), linear-gradient(90deg, ${ + settingsStore.settings.theme === "dark" ? "#ffffff" : "#000000" + } 1px, transparent 1px)`, + backgroundSize: "40px 40px", + maskImage: + "radial-gradient(circle at 50% 50%, black 30%, transparent 70%)", + }} + ></div> + </div> + + {/* Content Wrapper */} + <div className="relative z-10 flex h-full p-4 gap-4 text-gray-900 dark:text-white"> + {/* Floating Sidebar */} + <Sidebar /> + + {/* Main Content Area - Transparent & Flat */} + <main className="flex-1 flex flex-col relative min-w-0 overflow-hidden transition-all duration-300"> + {/* Window Drag Region */} + <div + className="h-8 w-full absolute top-0 left-0 z-50 drag-region" + data-tauri-drag-region + ></div> + + {/* App Content */} + <div className="flex-1 relative overflow-hidden flex flex-col"> + {/* Views Container */} + <div className="flex-1 relative overflow-hidden"> + <Outlet /> + </div> + + {/* Download Monitor Overlay */} + <div className="absolute bottom-20 left-4 right-4 pointer-events-none z-20"> + <div className="pointer-events-auto"> + <DownloadMonitor /> + </div> + </div> + + {/* Bottom Bar */} + {uiStore.currentView === "home" && <BottomBar />} + </div> + </main> + </div> + + {/* Logout Confirmation Dialog */} + {authStore.isLogoutConfirmOpen && ( + <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 p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200"> + <h3 className="text-lg font-bold text-white mb-2">Logout</h3> + <p className="text-zinc-400 text-sm mb-6"> + Are you sure you want to logout{" "} + <span className="text-white font-medium"> + {authStore.currentAccount?.username} + </span> + ? + </p> + <div className="flex gap-3 justify-end"> + <button + type="button" + onClick={() => authStore.cancelLogout()} + className="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors" + > + Cancel + </button> + <button + type="button" + onClick={() => authStore.confirmLogout()} + className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors" + > + Logout + </button> + </div> + </div> + </div> + )} + + {uiStore.showConsole && ( + <div className="fixed inset-0 z-100 bg-black/80 backdrop-blur-sm flex items-center justify-center p-8"> + <div className="w-full h-full max-w-6xl max-h-[85vh] bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-700 shadow-2xl relative flex flex-col"> + <GameConsole /> + </div> + </div> + )} + </div> + ); +} diff --git a/packages/ui-new/src/pages/index.tsx b/packages/ui-new/src/pages/index.tsx index 2b3c2b2..54cfc1e 100644 --- a/packages/ui-new/src/pages/index.tsx +++ b/packages/ui-new/src/pages/index.tsx @@ -1,94 +1,48 @@ import { useEffect } from "react"; -import { Outlet } from "react-router"; -import { BottomBar } from "@/components/bottom-bar"; -import { DownloadMonitor } from "@/components/download-monitor"; -import { GameConsole } from "@/components/game-console"; -import { LoginModal } from "@/components/login-modal"; +import { Outlet, useLocation } from "react-router"; import { ParticleBackground } from "@/components/particle-background"; import { Sidebar } from "@/components/sidebar"; - -import { useAuthStore } from "@/stores/auth-store"; -import { useGameStore } from "@/stores/game-store"; -import { useInstancesStore } from "@/stores/instances-store"; -import { useLogsStore } from "@/stores/logs-store"; -import { useSettingsStore } from "@/stores/settings-store"; -import { useUIStore } from "@/stores/ui-store"; +import { useAuthStore } from "@/models/auth"; +import { useSettingsStore } from "@/models/settings"; export function IndexPage() { const authStore = useAuthStore(); const settingsStore = useSettingsStore(); - const uiStore = useUIStore(); - const instancesStore = useInstancesStore(); - const gameStore = useGameStore(); - const logsStore = useLogsStore(); - useEffect(() => { - // ENFORCE DARK MODE: Always add 'dark' class and attribute - document.documentElement.classList.add("dark"); - document.documentElement.setAttribute("data-theme", "dark"); - document.documentElement.classList.remove("light"); - // Initialize stores - // Include store functions in the dependency array to satisfy hooks lint. - // These functions are stable in our store implementation, so listing them - // here is safe and prevents lint warnings. - authStore.checkAccount(); - settingsStore.loadSettings(); - logsStore.init(); - settingsStore.detectJava(); - instancesStore.loadInstances(); - gameStore.loadVersions(); + const location = useLocation(); - // Note: getVersion() would need Tauri API setup - // getVersion().then((v) => uiStore.setAppVersion(v)); - }, [ - authStore.checkAccount, - settingsStore.loadSettings, - logsStore.init, - settingsStore.detectJava, - instancesStore.loadInstances, - gameStore.loadVersions, - ]); - - // Refresh versions when active instance changes useEffect(() => { - if (instancesStore.activeInstanceId) { - gameStore.loadVersions(); - } else { - gameStore.setVersions([]); - } - }, [ - instancesStore.activeInstanceId, - gameStore.loadVersions, - gameStore.setVersions, - ]); + authStore.init(); + settingsStore.refresh(); + }, [authStore.init, settingsStore.refresh]); return ( - <div className="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30"> - {/* Modern Animated Background */} + <div className="relative h-screen w-full overflow-hidden bg-background font-sans"> <div className="absolute inset-0 z-0 bg-gray-100 dark:bg-[#09090b] overflow-hidden"> - {settingsStore.settings.customBackgroundPath && ( - <img - src={settingsStore.settings.customBackgroundPath} - alt="Background" - className="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear" - onError={(e) => console.error("Failed to load main background:", e)} - /> - )} - - {/* Dimming Overlay for readability */} - {settingsStore.settings.customBackgroundPath && ( - <div className="absolute inset-0 bg-black/50"></div> + {settingsStore.config?.customBackgroundPath && ( + <> + <img + src={settingsStore.config?.customBackgroundPath} + alt="Background" + className="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear" + onError={(e) => + console.error("Failed to load main background:", e) + } + /> + {/* Dimming Overlay for readability */} + <div className="absolute inset-0 bg-black/50" /> + </> )} - {!settingsStore.settings.customBackgroundPath && ( + {!settingsStore.config?.customBackgroundPath && ( <> - {settingsStore.settings.theme === "dark" ? ( + {settingsStore.theme === "dark" ? ( <div className="absolute inset-0 opacity-60 bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950"></div> ) : ( <div className="absolute inset-0 opacity-100 bg-linear-to-br from-emerald-100 via-gray-100 to-indigo-100"></div> )} - {uiStore.currentView === "home" && <ParticleBackground />} + {location.pathname === "/" && <ParticleBackground />} <div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50"></div> </> @@ -99,91 +53,24 @@ export function IndexPage() { className="absolute inset-0 z-0 dark:opacity-10 opacity-30 pointer-events-none" style={{ backgroundImage: `linear-gradient(${ - settingsStore.settings.theme === "dark" ? "#ffffff" : "#000000" + settingsStore.config?.theme === "dark" ? "#ffffff" : "#000000" } 1px, transparent 1px), linear-gradient(90deg, ${ - settingsStore.settings.theme === "dark" ? "#ffffff" : "#000000" + settingsStore.config?.theme === "dark" ? "#ffffff" : "#000000" } 1px, transparent 1px)`, backgroundSize: "40px 40px", maskImage: "radial-gradient(circle at 50% 50%, black 30%, transparent 70%)", }} - ></div> + /> </div> - {/* Content Wrapper */} - <div className="relative z-10 flex h-full p-4 gap-4 text-gray-900 dark:text-white"> - {/* Floating Sidebar */} + <div className="size-full flex flex-row p-4 space-x-4 z-20 relative"> <Sidebar /> - {/* Main Content Area - Transparent & Flat */} - <main className="flex-1 flex flex-col relative min-w-0 overflow-hidden transition-all duration-300"> - {/* Window Drag Region */} - <div - className="h-8 w-full absolute top-0 left-0 z-50 drag-region" - data-tauri-drag-region - ></div> - - {/* App Content */} - <div className="flex-1 relative overflow-hidden flex flex-col"> - {/* Views Container */} - <div className="flex-1 relative overflow-hidden"> - <Outlet /> - </div> - - {/* Download Monitor Overlay */} - <div className="absolute bottom-20 left-4 right-4 pointer-events-none z-20"> - <div className="pointer-events-auto"> - <DownloadMonitor /> - </div> - </div> - - {/* Bottom Bar */} - {uiStore.currentView === "home" && <BottomBar />} - </div> + <main className="size-full overflow-hidden"> + <Outlet /> </main> </div> - - <LoginModal /> - - {/* Logout Confirmation Dialog */} - {authStore.isLogoutConfirmOpen && ( - <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 p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200"> - <h3 className="text-lg font-bold text-white mb-2">Logout</h3> - <p className="text-zinc-400 text-sm mb-6"> - Are you sure you want to logout{" "} - <span className="text-white font-medium"> - {authStore.currentAccount?.username} - </span> - ? - </p> - <div className="flex gap-3 justify-end"> - <button - type="button" - onClick={() => authStore.cancelLogout()} - className="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors" - > - Cancel - </button> - <button - type="button" - onClick={() => authStore.confirmLogout()} - className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors" - > - Logout - </button> - </div> - </div> - </div> - )} - - {uiStore.showConsole && ( - <div className="fixed inset-0 z-100 bg-black/80 backdrop-blur-sm flex items-center justify-center p-8"> - <div className="w-full h-full max-w-6xl max-h-[85vh] bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-700 shadow-2xl relative flex flex-col"> - <GameConsole /> - </div> - </div> - )} </div> ); } diff --git a/packages/ui-new/src/pages/settings.tsx b/packages/ui-new/src/pages/settings.tsx new file mode 100644 index 0000000..440a5dc --- /dev/null +++ b/packages/ui-new/src/pages/settings.tsx @@ -0,0 +1,310 @@ +import { toNumber } from "es-toolkit/compat"; +import { FileJsonIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { migrateSharedCaches } from "@/client"; +import { ConfigEditor } from "@/components/config-editor"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Field, + FieldContent, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useSettingsStore } from "@/models/settings"; + +export type SettingsTab = "general" | "appearance" | "advanced"; + +export function SettingsPage() { + const { config, ...settings } = useSettingsStore(); + const [showConfigEditor, setShowConfigEditor] = useState<boolean>(false); + const [activeTab, setActiveTab] = useState<SettingsTab>("general"); + + useEffect(() => { + if (!config) settings.refresh(); + }, [config, settings.refresh]); + + const renderScrollArea = () => { + if (!config) { + return ( + <div className="size-full justify-center items-center"> + <Spinner /> + </div> + ); + } + return ( + <ScrollArea className="size-full pr-2"> + <TabsContent value="general" className="size-full"> + <Card className="size-full"> + <CardHeader> + <CardTitle className="font-bold text-xl">General</CardTitle> + </CardHeader> + <CardContent> + <FieldGroup> + <FieldSet> + <FieldLegend>Window Options</FieldLegend> + <FieldDescription> + May not work on some platforms like Linux Niri. + </FieldDescription> + <FieldGroup> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <Field> + <FieldLabel htmlFor="width"> + Window Default Width + </FieldLabel> + <Input + type="number" + name="width" + value={config?.width} + onChange={(e) => { + settings.merge({ + width: toNumber(e.target.value), + }); + }} + onBlur={() => { + settings.save(); + }} + min={800} + max={3840} + /> + </Field> + <Field> + <FieldLabel htmlFor="height"> + Window Default Height + </FieldLabel> + <Input + type="number" + name="height" + value={config?.height} + onChange={(e) => { + settings.merge({ + height: toNumber(e.target.value), + }); + }} + onBlur={() => { + settings.save(); + }} + min={600} + max={2160} + /> + </Field> + </div> + <Field className="flex flex-row items-center justify-between"> + <FieldContent> + <FieldLabel htmlFor="gpu-acceleration"> + GPU Acceleration + </FieldLabel> + <FieldDescription> + Enable GPU acceleration for the interface. + </FieldDescription> + </FieldContent> + <Switch + checked={config?.enableGpuAcceleration} + onCheckedChange={(checked) => { + settings.merge({ + enableGpuAcceleration: checked, + }); + settings.save(); + }} + /> + </Field> + </FieldGroup> + </FieldSet> + <FieldSet> + <FieldLegend>Network Options</FieldLegend> + <Field> + <Label htmlFor="download-threads">Download Threads</Label> + <Input + type="number" + name="download-threads" + value={config?.downloadThreads} + onChange={(e) => { + settings.merge({ + downloadThreads: toNumber(e.target.value), + }); + }} + onBlur={() => { + settings.save(); + }} + min={1} + max={64} + /> + </Field> + </FieldSet> + </FieldGroup> + </CardContent> + </Card> + </TabsContent> + <TabsContent value="java" className="size-full"> + <Card className="size-full"> + <CardHeader> + <CardTitle className="font-bold text-xl"> + Java Installations + </CardTitle> + <CardContent></CardContent> + </CardHeader> + </Card> + </TabsContent> + <TabsContent value="appearance" className="size-full"> + <Card className="size-full"> + <CardHeader> + <CardTitle className="font-bold text-xl">Appearance</CardTitle> + </CardHeader> + <CardContent> + <FieldGroup> + <Field className="flex flex-row"> + <FieldContent> + <FieldLabel htmlFor="theme">Theme</FieldLabel> + <FieldDescription> + Select your prefered theme. + </FieldDescription> + </FieldContent> + <Select + items={[ + { label: "Dark", value: "dark" }, + { label: "Light", value: "light" }, + { label: "System", value: "system" }, + ]} + value={config.theme} + onValueChange={async (value) => { + if ( + value === "system" || + value === "light" || + value === "dark" + ) { + settings.merge({ theme: value }); + await settings.save(); + settings.applyTheme(value); + } + }} + > + <SelectTrigger className="w-full max-w-48"> + <SelectValue placeholder="Please select a prefered theme" /> + </SelectTrigger> + <SelectContent alignItemWithTrigger={false}> + <SelectGroup> + <SelectItem value="system">System</SelectItem> + <SelectItem value="light">Light</SelectItem> + <SelectItem value="dark">Dark</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </Field> + </FieldGroup> + </CardContent> + </Card> + </TabsContent> + <TabsContent value="advanced" className="size-full"> + <Card className="size-full"> + <CardHeader> + <CardTitle className="font-bold text-xl">Advanced</CardTitle> + </CardHeader> + <CardContent> + <FieldGroup> + <FieldSet> + <FieldLegend>Advanced Options</FieldLegend> + <FieldGroup> + <Field className="flex flex-row items-center justify-between"> + <FieldContent> + <FieldLabel htmlFor="use-shared-caches"> + Use Shared Caches + </FieldLabel> + <FieldDescription> + Share downloaded assets between instances. + </FieldDescription> + </FieldContent> + <Switch + checked={config?.useSharedCaches} + onCheckedChange={async (checked) => { + checked && (await migrateSharedCaches()); + settings.merge({ + useSharedCaches: checked, + }); + settings.save(); + }} + /> + </Field> + <Field className="flex flex-row items-center justify-between"> + <FieldContent> + <FieldLabel htmlFor="keep-per-instance-storage"> + Keep Legacy Per-Instance Storage + </FieldLabel> + <FieldDescription> + Maintain separate cache folders for compatibility. + </FieldDescription> + </FieldContent> + <Switch + checked={config?.keepLegacyPerInstanceStorage} + onCheckedChange={(checked) => { + settings.merge({ + keepLegacyPerInstanceStorage: checked, + }); + settings.save(); + }} + /> + </Field> + </FieldGroup> + </FieldSet> + </FieldGroup> + </CardContent> + </Card> + </TabsContent> + </ScrollArea> + ); + }; + + return ( + <div className="size-full flex flex-col p-6 space-y-6"> + <div className="flex items-center justify-between"> + <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600"> + Settings + </h2> + + <Button + variant="outline" + size="sm" + onClick={() => setShowConfigEditor(true)} + > + <FileJsonIcon /> + <span className="hidden sm:inline">Open JSON</span> + </Button> + </div> + + <Tabs + value={activeTab} + onValueChange={setActiveTab} + className="size-full flex flex-col gap-6" + > + <TabsList> + <TabsTrigger value="general">General</TabsTrigger> + <TabsTrigger value="java">Java</TabsTrigger> + <TabsTrigger value="appearance">Appearance</TabsTrigger> + <TabsTrigger value="advanced">Advanced</TabsTrigger> + </TabsList> + {renderScrollArea()} + </Tabs> + + <ConfigEditor + open={showConfigEditor} + onOpenChange={() => setShowConfigEditor(false)} + /> + </div> + ); +} diff --git a/packages/ui-new/src/types/bindings/auth.ts b/packages/ui-new/src/types/bindings/auth.ts index a65f0a4..563a924 100644 --- a/packages/ui-new/src/types/bindings/auth.ts +++ b/packages/ui-new/src/types/bindings/auth.ts @@ -26,7 +26,7 @@ export type MinecraftProfile = { id: string; name: string }; export type OfflineAccount = { username: string; uuid: string }; export type TokenResponse = { - accessToken: string; - refreshToken: string | null; - expiresIn: bigint; + access_token: string; + refresh_token: string | null; + expires_in: bigint; }; diff --git a/packages/ui-new/src/types/bindings/java/core.ts b/packages/ui-new/src/types/bindings/java/core.ts index d0dfcbd..8094c71 100644 --- a/packages/ui-new/src/types/bindings/java/core.ts +++ b/packages/ui-new/src/types/bindings/java/core.ts @@ -2,9 +2,9 @@ export type JavaCatalog = { releases: Array<JavaReleaseInfo>; - available_major_versions: Array<number>; - lts_versions: Array<number>; - cached_at: bigint; + availableMajorVersions: Array<number>; + ltsVersions: Array<number>; + cachedAt: bigint; }; export type JavaDownloadInfo = { @@ -27,15 +27,15 @@ export type JavaInstallation = { }; export type JavaReleaseInfo = { - major_version: number; - image_type: string; + majorVersion: number; + imageType: string; version: string; - release_name: string; - release_date: string | null; - file_size: bigint; + releaseName: string; + releaseDate: string | null; + fileSize: bigint; checksum: string | null; - download_url: string; - is_lts: boolean; - is_available: boolean; + downloadUrl: string; + isLts: boolean; + isAvailable: boolean; architecture: string; }; |